Merge remote-tracking branch 'origin/dev' into fix/gpt-apply-patch-rebased
This commit is contained in:
@@ -47,7 +47,7 @@ jobs:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
run: bun run script/run-ci-tests.ts
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
run: bun run script/run-ci-tests.ts
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# oh-my-opencode — O P E N C O D E Plugin
|
||||
|
||||
**Generated:** 2026-04-05 | **Commit:** c9be5bb51 | **Branch:** dev
|
||||
**Generated:** 2026-04-08 | **Commit:** 4f196f49 | **Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"title": "Oh My OpenCode Configuration",
|
||||
"description": "Configuration schema for oh-my-opencode plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
@@ -6068,8 +6071,5 @@
|
||||
"required": [
|
||||
"git_master"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"title": "Oh My OpenCode Configuration",
|
||||
"description": "Configuration schema for oh-my-opencode plugin"
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opencode-ai/plugin": "^1.2.24",
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
"@opencode-ai/plugin": "^1.4.0",
|
||||
"@opencode-ai/sdk": "^1.4.0",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -20,8 +20,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod": "^3.24.0",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zod": "^4.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -30,17 +29,17 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
||||
"oh-my-opencode-linux-x64": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
||||
"oh-my-opencode-windows-x64": "3.15.3",
|
||||
"oh-my-opencode-windows-x64-baseline": "3.15.3",
|
||||
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||
"oh-my-opencode-linux-x64": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||
"oh-my-opencode-windows-x64": "3.16.0",
|
||||
"oh-my-opencode-windows-x64-baseline": "3.16.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -49,9 +48,6 @@
|
||||
"@ast-grep/napi",
|
||||
"@code-yeongyu/comment-checker",
|
||||
],
|
||||
"overrides": {
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
},
|
||||
"packages": {
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
||||
|
||||
@@ -99,9 +95,9 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.24", "", { "dependencies": { "@opencode-ai/sdk": "1.2.24", "zod": "4.1.8" } }, "sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
@@ -239,28 +235,6 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.15.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-FApyQE45gv3VFwS/7iLS1/84v4iTX6BIVNcYYU2faqPazcZkvenkMbtxuWRfohQyZ1lhADopnjUcqOdcKjLDGQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-h4fr0/myoyvvytdizfLNQgRAWK+hw+1tW32rgL7ENLv1JQ8ChXHnHKEQ2saEqGfn1SuXvA0xUTsFMYR8q3mnbA=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zhi5xGcEhirHcx95kZtABYlIdSt6a5L5+T+exR4Kcnu+KR1mJ6li9n3UBIiW8eVgDz2ls7W25ePD78xRlqnxlg=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+lDsQMPfXGCrwe9vqHdmp1tCJ8PV+5OkKueVorRwXNfiZNOW3848TKxtW3QdkKopiBKejEaDfyu/IGSgWQ/iyQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-cokhNYK+dBVPRmZ2bYd3ZNp7dSGZdko77qUaeb0jjALFWkNzzmFgOV0spgOGZ3iS+yMS1XjAheTo5Qswh0capQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8+57NMUwdcc2DZGX6KlNb1EchTB6xmwiiHcRhFZpYiAB1GCUFNeWihq3D7r5GUtOs0zQYWUT/F1Rj2nzBxuy+A=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1awTpjU8m1cLF+GiiT7BuK5+y+WvTZwAaBZzYrJBzldiqdqMGJVYaH/uLiKt6CdZ0T6jh0zR/v85VFZIaXRusQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-WhmJ9ZwXxe3Nv0sVnFN3ibykie1JDiXthOmErhtKbcAL9V25IDYSbTcjxY2jUq0rNr4PeTvBva+WkMW4k9438w=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Gx2YitS/Ydg1XdwZMAH186ABvHGPlnuVA/1j7nGdARIwNM/xz6bZRq+kaeMmlj2N1U63unMOHe1ibE6nL1oZSw=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-Q6xskcBlBqUT77OK+7oHID9McrHu6t5+P/YCaDU/zLvr1T8M0Z5WgakM5hRsqCI8e4P1NEX6wHtwQNbVfUgo1w=="],
|
||||
|
||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-2BlXtH+DrSRPFGEOtfY1mlROOXFeWQbG/EpDw0JD27s7QQOkShaDff8Vc48PnmD1H8vW4d7/o/eP8jJPPjGQ0w=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -331,12 +305,10 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
+16
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "./dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -59,8 +59,8 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opencode-ai/plugin": "^1.2.24",
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
"@opencode-ai/plugin": "^1.4.0",
|
||||
"@opencode-ai/sdk": "^1.4.0",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -69,8 +69,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zod": "^3.24.0"
|
||||
"zod": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -79,21 +78,19 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
||||
"oh-my-opencode-linux-x64": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
||||
"oh-my-opencode-windows-x64": "3.15.3",
|
||||
"oh-my-opencode-windows-x64-baseline": "3.15.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@opencode-ai/sdk": "^1.2.24"
|
||||
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||
"oh-my-opencode-linux-x64": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||
"oh-my-opencode-windows-x64": "3.16.0",
|
||||
"oh-my-opencode-windows-x64-baseline": "3.16.0"
|
||||
},
|
||||
"overrides": {},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-baseline",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64-baseline",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.15.3",
|
||||
"version": "3.16.0",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
+63
-1
@@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const MIN_OPENCODE_VERSION = "1.4.0";
|
||||
|
||||
/**
|
||||
* Parse version string into numeric parts
|
||||
* @param {string} version
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function parseVersion(version) {
|
||||
return version
|
||||
.replace(/^v/, "")
|
||||
.split("-")[0]
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings
|
||||
* @param {string} current
|
||||
* @param {string} minimum
|
||||
* @returns {boolean} true if current >= minimum
|
||||
*/
|
||||
function compareVersions(current, minimum) {
|
||||
const currentParts = parseVersion(current);
|
||||
const minimumParts = parseVersion(minimum);
|
||||
const length = Math.max(currentParts.length, minimumParts.length);
|
||||
|
||||
for (let index = 0; index < length; index++) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const minimumPart = minimumParts[index] ?? 0;
|
||||
if (currentPart > minimumPart) return true;
|
||||
if (currentPart < minimumPart) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if opencode version meets minimum requirement
|
||||
* @returns {{ok: boolean, version: string | null}}
|
||||
*/
|
||||
function checkOpenCodeVersion() {
|
||||
try {
|
||||
const result = require("child_process").execSync("opencode --version", {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
const version = result.trim();
|
||||
const ok = compareVersions(version, MIN_OPENCODE_VERSION);
|
||||
return { ok, version };
|
||||
} catch {
|
||||
return { ok: true, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect libc family on Linux
|
||||
*/
|
||||
@@ -36,7 +90,15 @@ function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
const packageBaseName = getPackageBaseName();
|
||||
|
||||
|
||||
// Check opencode version requirement
|
||||
const versionCheck = checkOpenCodeVersion();
|
||||
if (versionCheck.version && !versionCheck.ok) {
|
||||
console.warn(`⚠ oh-my-opencode requires OpenCode >= ${MIN_OPENCODE_VERSION}`);
|
||||
console.warn(` Detected: ${versionCheck.version}`);
|
||||
console.warn(` Please update OpenCode to avoid compatibility issues.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageCandidates = getPlatformPackageCandidates({
|
||||
platform,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { z } from "zod"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
||||
const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema) as Record<string, unknown>
|
||||
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
|
||||
target: "draft-7",
|
||||
unrepresentable: "any",
|
||||
}) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
...jsonSchema,
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("test workflows", () => {
|
||||
const workflow = readFileSync(workflowPath, "utf8")
|
||||
|
||||
expect(workflow).toContain("- name: Run tests")
|
||||
expect(workflow).toContain("run: bun test")
|
||||
expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise<
|
||||
}
|
||||
|
||||
function toIsolatedTarget(testFile: string): string {
|
||||
const pathSegments = testFile.split("/")
|
||||
|
||||
if (pathSegments.length <= 3) {
|
||||
return testFile
|
||||
}
|
||||
|
||||
return pathSegments.slice(0, -1).join("/")
|
||||
return testFile
|
||||
}
|
||||
|
||||
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
|
||||
@@ -2599,6 +2599,54 @@
|
||||
"created_at": "2026-04-06T15:37:54Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3160
|
||||
},
|
||||
{
|
||||
"name": "teneburu",
|
||||
"id": 43727604,
|
||||
"comment_id": 4199167526,
|
||||
"created_at": "2026-04-07T13:06:07Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3203
|
||||
},
|
||||
{
|
||||
"name": "dhruvkej9",
|
||||
"id": 96516827,
|
||||
"comment_id": 4204071246,
|
||||
"created_at": "2026-04-08T05:36:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3217
|
||||
},
|
||||
{
|
||||
"name": "dhruvkej9",
|
||||
"id": 96516827,
|
||||
"comment_id": 4204084942,
|
||||
"created_at": "2026-04-08T05:40:40Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3217
|
||||
},
|
||||
{
|
||||
"name": "FrancoStino",
|
||||
"id": 32127923,
|
||||
"comment_id": 4205715582,
|
||||
"created_at": "2026-04-08T10:52:39Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3234
|
||||
},
|
||||
{
|
||||
"name": "sen7971",
|
||||
"id": 193416996,
|
||||
"comment_id": 4207621925,
|
||||
"created_at": "2026-04-08T15:57:15Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3248
|
||||
},
|
||||
{
|
||||
"name": "NikkeTryHard",
|
||||
"id": 111729769,
|
||||
"comment_id": 4210843488,
|
||||
"created_at": "2026-04-09T01:34:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3261
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { buildAgentIdentitySection } from "./dynamic-agent-core-sections"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { mergeAgentConfig } from "./builtin-agents/agent-overrides"
|
||||
|
||||
describe("buildAgentIdentitySection", () => {
|
||||
describe("#given an agent name and role description", () => {
|
||||
describe("#when building the identity section", () => {
|
||||
it("#then includes the agent name prominently", () => {
|
||||
const result = buildAgentIdentitySection("Sisyphus", "Powerful AI orchestrator from OhMyOpenCode")
|
||||
|
||||
expect(result).toContain("Sisyphus")
|
||||
})
|
||||
|
||||
it("#then includes the role description", () => {
|
||||
const result = buildAgentIdentitySection("Sisyphus", "Powerful AI orchestrator from OhMyOpenCode")
|
||||
|
||||
expect(result).toContain("Powerful AI orchestrator from OhMyOpenCode")
|
||||
})
|
||||
|
||||
it("#then wraps content in an identity XML tag", () => {
|
||||
const result = buildAgentIdentitySection("Hephaestus", "Autonomous deep worker")
|
||||
|
||||
expect(result).toContain("<agent-identity>")
|
||||
expect(result).toContain("</agent-identity>")
|
||||
})
|
||||
|
||||
it("#then explicitly states this identity overrides any prior identity", () => {
|
||||
const result = buildAgentIdentitySection("Sisyphus", "Powerful AI orchestrator from OhMyOpenCode")
|
||||
|
||||
expect(result).toMatch(/override|supersede|replace|disregard|instead of/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given different agent names", () => {
|
||||
describe("#when building identity for each", () => {
|
||||
it("#then each identity section contains the correct agent name", () => {
|
||||
const sisyphus = buildAgentIdentitySection("Sisyphus", "AI orchestrator")
|
||||
const hephaestus = buildAgentIdentitySection("Hephaestus", "Autonomous deep worker")
|
||||
const oracle = buildAgentIdentitySection("Oracle", "Strategic advisor")
|
||||
|
||||
expect(sisyphus).toContain("Sisyphus")
|
||||
expect(sisyphus).not.toContain("Hephaestus")
|
||||
expect(hephaestus).toContain("Hephaestus")
|
||||
expect(hephaestus).not.toContain("Sisyphus")
|
||||
expect(oracle).toContain("Oracle")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Sisyphus prompt identity", () => {
|
||||
describe("#given a Sisyphus agent created with default model", () => {
|
||||
describe("#when checking the prompt", () => {
|
||||
it("#then contains the agent identity section with override directive", () => {
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
|
||||
expect(config.prompt).toContain("<agent-identity>")
|
||||
expect(config.prompt).toContain("Sisyphus")
|
||||
expect(config.prompt).toContain("</agent-identity>")
|
||||
})
|
||||
|
||||
it("#then identity section appears before the Role section", () => {
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const prompt = config.prompt ?? ""
|
||||
const identityIndex = prompt.indexOf("<agent-identity>")
|
||||
const roleIndex = prompt.indexOf("<Role>")
|
||||
|
||||
expect(identityIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(roleIndex).toBeGreaterThan(identityIndex)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Sisyphus agent created with GPT-5.4 model", () => {
|
||||
describe("#when checking the prompt", () => {
|
||||
it("#then contains the agent identity section", () => {
|
||||
const config = createSisyphusAgent("openai/gpt-5.4")
|
||||
|
||||
expect(config.prompt).toContain("<agent-identity>")
|
||||
expect(config.prompt).toContain("Sisyphus")
|
||||
expect(config.prompt).toContain("</agent-identity>")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hephaestus prompt identity", () => {
|
||||
describe("#given a Hephaestus agent created with GPT model", () => {
|
||||
describe("#when checking the prompt", () => {
|
||||
it("#then contains the agent identity section", () => {
|
||||
const config = createHephaestusAgent("openai/gpt-5.4")
|
||||
|
||||
expect(config.prompt).toContain("<agent-identity>")
|
||||
expect(config.prompt).toContain("Hephaestus")
|
||||
expect(config.prompt).toContain("</agent-identity>")
|
||||
})
|
||||
|
||||
it("#then identity section appears at the start of the prompt", () => {
|
||||
const config = createHephaestusAgent("openai/gpt-5.4")
|
||||
const prompt = config.prompt ?? ""
|
||||
const identityIndex = prompt.indexOf("<agent-identity>")
|
||||
|
||||
expect(identityIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Agent identity preservation through overrides", () => {
|
||||
describe("#given a Sisyphus agent with prompt_append override", () => {
|
||||
describe("#when merging the override", () => {
|
||||
it("#then identity section is preserved in the merged prompt", () => {
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const merged = mergeAgentConfig(baseConfig, { prompt_append: "Extra instructions here" })
|
||||
|
||||
expect(merged.prompt).toContain("<agent-identity>")
|
||||
expect(merged.prompt).toContain("Sisyphus")
|
||||
expect(merged.prompt).toContain("</agent-identity>")
|
||||
expect(merged.prompt).toContain("Extra instructions here")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Sisyphus agent with model override only", () => {
|
||||
describe("#when merging the override", () => {
|
||||
it("#then identity section is preserved unchanged", () => {
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const merged = mergeAgentConfig(baseConfig, { model: "openai/gpt-5.4" })
|
||||
|
||||
expect(merged.prompt).toContain("<agent-identity>")
|
||||
expect(merged.prompt).toContain("Sisyphus")
|
||||
expect(merged.prompt).toContain("</agent-identity>")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import { buildAgentIdentitySection, buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
|
||||
@@ -88,9 +88,13 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
||||
const skillsSection = buildSkillsSection(skills)
|
||||
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
|
||||
|
||||
const agentIdentity = buildAgentIdentitySection(
|
||||
"Atlas",
|
||||
"Master Orchestrator agent from OhMyOpenCode that coordinates specialized agents to complete todo lists",
|
||||
)
|
||||
const basePrompt = getAtlasPrompt(model)
|
||||
|
||||
return basePrompt
|
||||
return agentIdentity + "\n" + basePrompt
|
||||
.replace("{CATEGORY_SECTION}", categorySection)
|
||||
.replace("{AGENT_SECTION}", agentSection)
|
||||
.replace("{DECISION_MATRIX}", decisionMatrix)
|
||||
|
||||
@@ -6,6 +6,23 @@ import type {
|
||||
import type { AvailableTool } from "./dynamic-agent-prompt-types"
|
||||
import { getToolsPromptDisplay } from "./dynamic-agent-tool-categorization"
|
||||
|
||||
/**
|
||||
* Builds an explicit agent identity preamble that overrides any base system prompt identity.
|
||||
* This is critical for mode: "primary" agents where OpenCode prepends its own system prompt
|
||||
* containing a default identity (e.g., "You are Claude"). Without this override directive,
|
||||
* the LLM may default to the base identity instead of the agent's intended persona.
|
||||
*/
|
||||
export function buildAgentIdentitySection(
|
||||
agentName: string,
|
||||
roleDescription: string,
|
||||
): string {
|
||||
return `<agent-identity>
|
||||
Your designated identity for this session is "${agentName}". This identity supersedes any prior identity statements.
|
||||
You are "${agentName}" - ${roleDescription}.
|
||||
When asked who you are, always identify as ${agentName}. Do not identify as any other assistant or AI.
|
||||
</agent-identity>`
|
||||
}
|
||||
|
||||
export function buildKeyTriggersSection(
|
||||
agents: AvailableAgent[],
|
||||
_skills: AvailableSkill[] = [],
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
export { categorizeTools } from "./dynamic-agent-tool-categorization"
|
||||
|
||||
export {
|
||||
buildAgentIdentitySection,
|
||||
buildKeyTriggersSection,
|
||||
buildToolSelectionTable,
|
||||
buildExploreSection,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
AvailableSkill,
|
||||
AvailableCategory,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
import { categorizeTools } from "../dynamic-agent-prompt-builder";
|
||||
import { categorizeTools, buildAgentIdentitySection } from "../dynamic-agent-prompt-builder";
|
||||
|
||||
import { buildHephaestusPrompt as buildGptPrompt } from "./gpt";
|
||||
import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex";
|
||||
@@ -87,7 +87,12 @@ function buildDynamicHephaestusPrompt(ctx?: HephaestusContext): string {
|
||||
break;
|
||||
}
|
||||
|
||||
return basePrompt;
|
||||
const agentIdentity = buildAgentIdentitySection(
|
||||
"Hephaestus",
|
||||
"Autonomous deep worker for software engineering from OhMyOpenCode",
|
||||
);
|
||||
|
||||
return `${agentIdentity}\n${basePrompt}`;
|
||||
}
|
||||
|
||||
export function createHephaestusAgent(
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
AvailableCategory,
|
||||
} from "./dynamic-agent-prompt-builder";
|
||||
import {
|
||||
buildAgentIdentitySection,
|
||||
buildKeyTriggersSection,
|
||||
buildToolSelectionTable,
|
||||
buildExploreSection,
|
||||
@@ -72,7 +73,13 @@ function buildDynamicSisyphusPrompt(
|
||||
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
||||
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])";
|
||||
|
||||
return `<Role>
|
||||
const agentIdentity = buildAgentIdentitySection(
|
||||
"Sisyphus",
|
||||
"Powerful AI Agent with orchestration capabilities from OhMyOpenCode",
|
||||
);
|
||||
|
||||
return `${agentIdentity}
|
||||
<Role>
|
||||
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
|
||||
|
||||
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different-your code should be indistinguishable from a senior engineer's.
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
AvailableCategory,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
import {
|
||||
buildAgentIdentitySection,
|
||||
buildKeyTriggersSection,
|
||||
buildToolSelectionTable,
|
||||
buildExploreSection,
|
||||
@@ -106,6 +107,11 @@ export function buildGpt54SisyphusPrompt(
|
||||
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
||||
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])";
|
||||
|
||||
const agentIdentity = buildAgentIdentitySection(
|
||||
"Sisyphus",
|
||||
"Powerful AI Agent with orchestration capabilities from OhMyOpenCode",
|
||||
);
|
||||
|
||||
const identityBlock = `<identity>
|
||||
You are Sisyphus - an AI orchestrator from OhMyOpenCode.
|
||||
|
||||
@@ -421,7 +427,8 @@ If the user's approach has a problem, explain the concern directly and clearly,
|
||||
</verbosity_controls>
|
||||
</style>`;
|
||||
|
||||
return `${identityBlock}
|
||||
return `${agentIdentity}
|
||||
${identityBlock}
|
||||
|
||||
${constraintsBlock}
|
||||
|
||||
|
||||
@@ -915,6 +915,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -1132,6 +1140,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -1353,6 +1369,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
],
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -1534,6 +1555,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
],
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -1842,6 +1868,17 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -2114,6 +2151,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
],
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -2353,6 +2395,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"librarian": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
],
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
@@ -2573,6 +2620,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -2586,6 +2644,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7-highspeed",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
@@ -2949,6 +3018,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -2966,6 +3049,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7-highspeed",
|
||||
},
|
||||
{
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
@@ -3472,6 +3569,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "github-copilot/grok-code-fast-1",
|
||||
},
|
||||
{
|
||||
"model": "opencode/minimax-m2.7",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
@@ -3489,6 +3600,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "opencode/minimax-m2.7-highspeed",
|
||||
},
|
||||
{
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
],
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
|
||||
@@ -21,11 +21,12 @@ describe("runCliInstaller", () => {
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
it("completes installation without auth plugin or provider config steps", async () => {
|
||||
//#given
|
||||
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||
// given
|
||||
const restoreSpies = [
|
||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
@@ -34,9 +35,56 @@ describe("runCliInstaller", () => {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||
]
|
||||
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
|
||||
|
||||
const args: InstallArgs = {
|
||||
tui: false,
|
||||
claude: "no",
|
||||
openai: "no",
|
||||
gemini: "no",
|
||||
copilot: "no",
|
||||
opencodeZen: "no",
|
||||
zaiCodingPlan: "no",
|
||||
kimiForCoding: "no",
|
||||
opencodeGo: "no",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await runCliInstaller(args, "3.16.0")
|
||||
|
||||
// then
|
||||
expect(result).toBe(1)
|
||||
expect(addPluginSpy).not.toHaveBeenCalled()
|
||||
|
||||
for (const spy of restoreSpies) {
|
||||
spy.mockRestore()
|
||||
}
|
||||
addPluginSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("completes installation without auth plugin or provider config steps", async () => {
|
||||
// given
|
||||
const restoreSpies = [
|
||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||
success: true,
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
@@ -56,12 +104,13 @@ describe("runCliInstaller", () => {
|
||||
opencodeZen: "no",
|
||||
zaiCodingPlan: "no",
|
||||
kimiForCoding: "no",
|
||||
opencodeGo: "no",
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = await runCliInstaller(args, "3.4.0")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
|
||||
for (const spy of restoreSpies) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
printWarning,
|
||||
validateNonTuiArgs,
|
||||
} from "./install-validators"
|
||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||
|
||||
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||
const validation = validateNonTuiArgs(args)
|
||||
@@ -57,6 +58,12 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
||||
printInfo("Visit https://opencode.ai/docs for installation instructions")
|
||||
} else {
|
||||
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
|
||||
|
||||
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||
if (unsupportedVersionMessage) {
|
||||
printWarning(unsupportedVersionMessage)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
|
||||
@@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config"
|
||||
|
||||
export type { BunInstallResult } from "./config-manager/bun-install"
|
||||
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
|
||||
|
||||
export type { VersionCompatibility } from "./config-manager/version-compatibility"
|
||||
export {
|
||||
checkVersionCompatibility,
|
||||
extractVersionFromPluginEntry,
|
||||
} from "./config-manager/version-compatibility"
|
||||
|
||||
export type { BackupResult } from "./config-manager/backup-config"
|
||||
export { backupConfigFile } from "./config-manager/backup-config"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import type { ConfigMergeResult } from "../types"
|
||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||
import { backupConfigFile } from "./backup-config"
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
|
||||
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
@@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
&& !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`))
|
||||
)
|
||||
|
||||
const existingEntry = canonicalEntries[0] ?? legacyEntries[0]
|
||||
if (existingEntry) {
|
||||
const installedVersion = extractVersionFromPluginEntry(existingEntry)
|
||||
const compatibility = checkVersionCompatibility(installedVersion, currentVersion)
|
||||
|
||||
if (!compatibility.canUpgrade) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: path,
|
||||
error: compatibility.reason ?? "Version compatibility check failed",
|
||||
}
|
||||
}
|
||||
|
||||
const backupResult = backupConfigFile(path)
|
||||
if (!backupResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: path,
|
||||
error: `Failed to create backup: ${backupResult.error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPlugins = [...otherPlugins]
|
||||
|
||||
if (canonicalEntries.length > 0) {
|
||||
normalizedPlugins.push(canonicalEntries[0])
|
||||
} else if (legacyEntries.length > 0) {
|
||||
const versionMatch = legacyEntries[0].match(/@(.+)$/)
|
||||
const preservedVersion = versionMatch ? versionMatch[1] : null
|
||||
normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry)
|
||||
if (canonicalEntries.length > 0 || legacyEntries.length > 0) {
|
||||
normalizedPlugins.push(pluginEntry)
|
||||
} else {
|
||||
normalizedPlugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean
|
||||
backupPath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function backupConfigFile(configPath: string): BackupResult {
|
||||
if (!existsSync(configPath)) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const backupPath = `${configPath}.backup-${timestamp}`
|
||||
|
||||
try {
|
||||
const dir = dirname(backupPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
copyFileSync(configPath, backupPath)
|
||||
return { success: true, backupPath }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to create backup",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { DetectedConfig } from "../types"
|
||||
import { getOmoConfigPath } from "./config-context"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
||||
import { extractVersionFromPluginEntry } from "./version-compatibility"
|
||||
|
||||
function detectProvidersFromOmoConfig(): {
|
||||
hasOpenAI: boolean
|
||||
@@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean {
|
||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
}
|
||||
|
||||
function findOurPluginEntry(plugins: string[]): string | null {
|
||||
return plugins.find(isOurPlugin) ?? null
|
||||
}
|
||||
|
||||
export function detectCurrentConfig(): DetectedConfig {
|
||||
const result: DetectedConfig = {
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: true,
|
||||
isMax20: true,
|
||||
hasOpenAI: true,
|
||||
@@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
const openCodeConfig = parseResult.config
|
||||
const plugins = openCodeConfig.plugin ?? []
|
||||
result.isInstalled = plugins.some(isOurPlugin)
|
||||
const ourPluginEntry = findOurPluginEntry(plugins)
|
||||
result.isInstalled = !!ourPluginEntry
|
||||
|
||||
if (ourPluginEntry) {
|
||||
result.installedVersion = extractVersionFromPluginEntry(ourPluginEntry)
|
||||
}
|
||||
|
||||
if (!result.isInstalled) {
|
||||
return result
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
@@ -6,6 +6,7 @@ import { join } from "node:path"
|
||||
import { resetConfigContext } from "./config-context"
|
||||
import { detectCurrentConfig } from "./detect-current-config"
|
||||
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
||||
import * as pluginNameWithVersion from "./plugin-name-with-version"
|
||||
|
||||
describe("detectCurrentConfig - single package detection", () => {
|
||||
let testConfigDir = ""
|
||||
@@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||
})
|
||||
|
||||
it("upgrades a version-pinned legacy entry to canonical", async () => {
|
||||
it("updates a version-pinned legacy entry to the requested version", async () => {
|
||||
// given
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.16.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.15.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
const result = await addPluginToOpenCodeConfig("3.16.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("removes stale legacy entry when canonical and legacy entries both exist", async () => {
|
||||
@@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||
})
|
||||
|
||||
it("preserves a canonical entry when it already exists", async () => {
|
||||
it("preserves a canonical entry when the same version is re-installed", async () => {
|
||||
// given
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.10.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
const result = await addPluginToOpenCodeConfig("3.10.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("blocks a downgrade for a version-pinned canonical entry", async () => {
|
||||
// given
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.16.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.15.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Downgrade")
|
||||
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("rewrites quoted jsonc plugin field in place", async () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
checkVersionCompatibility,
|
||||
extractVersionFromPluginEntry,
|
||||
} from "./version-compatibility"
|
||||
|
||||
describe("checkVersionCompatibility", () => {
|
||||
it("allows fresh install when no current version", () => {
|
||||
const result = checkVersionCompatibility(null, "3.15.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isDowngrade).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("detects same version as already installed", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.15.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.reason).toContain("already installed")
|
||||
})
|
||||
|
||||
it("blocks downgrade from higher to lower version", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.14.0")
|
||||
expect(result.canUpgrade).toBe(false)
|
||||
expect(result.isDowngrade).toBe(true)
|
||||
expect(result.reason).toContain("Downgrade")
|
||||
})
|
||||
|
||||
it("allows patch version upgrade", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.15.1")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("allows minor version upgrade", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("detects major version bump requiring migration", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "4.0.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(true)
|
||||
expect(result.requiresMigration).toBe(true)
|
||||
expect(result.reason).toContain("Major version upgrade")
|
||||
})
|
||||
|
||||
it("handles v prefix in versions", () => {
|
||||
const result = checkVersionCompatibility("v3.15.0", "v3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isDowngrade).toBe(false)
|
||||
})
|
||||
|
||||
it("handles mixed v prefix", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "v3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractVersionFromPluginEntry", () => {
|
||||
it("extracts version from canonical plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent@3.15.0")
|
||||
expect(version).toBe("3.15.0")
|
||||
})
|
||||
|
||||
it("extracts version from legacy plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-opencode@3.14.0")
|
||||
expect(version).toBe("3.14.0")
|
||||
})
|
||||
|
||||
it("returns null for bare plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent")
|
||||
expect(version).toBeNull()
|
||||
})
|
||||
|
||||
it("handles prerelease versions", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent@3.16.0-beta.1")
|
||||
expect(version).toBe("3.16.0-beta.1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export interface VersionCompatibility {
|
||||
canUpgrade: boolean
|
||||
reason?: string
|
||||
isDowngrade: boolean
|
||||
isMajorBump: boolean
|
||||
requiresMigration: boolean
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
const clean = version.replace(/^v/, "").split("-")[0]
|
||||
return clean.split(".").map(Number)
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const partsA = parseVersion(a)
|
||||
const partsB = parseVersion(b)
|
||||
const maxLen = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const numA = partsA[i] ?? 0
|
||||
const numB = partsB[i] ?? 0
|
||||
if (numA !== numB) {
|
||||
return numA - numB
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function checkVersionCompatibility(
|
||||
currentVersion: string | null,
|
||||
newVersion: string
|
||||
): VersionCompatibility {
|
||||
if (!currentVersion) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
const cleanCurrent = currentVersion.replace(/^v/, "")
|
||||
const cleanNew = newVersion.replace(/^v/, "")
|
||||
|
||||
try {
|
||||
const comparison = compareVersions(cleanNew, cleanCurrent)
|
||||
|
||||
if (comparison < 0) {
|
||||
return {
|
||||
canUpgrade: false,
|
||||
reason: `Downgrade from ${currentVersion} to ${newVersion} is not allowed`,
|
||||
isDowngrade: true,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (comparison === 0) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Version ${newVersion} is already installed`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
const currentMajor = cleanCurrent.split(".")[0]
|
||||
const newMajor = cleanNew.split(".")[0]
|
||||
const isMajorBump = currentMajor !== newMajor
|
||||
|
||||
if (isMajorBump) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Major version upgrade from ${currentVersion} to ${newVersion} - configuration migration may be required`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: true,
|
||||
requiresMigration: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canUpgrade: true,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Unable to compare versions ${currentVersion} and ${newVersion} - proceeding with caution`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractVersionFromPluginEntry(entry: string): string | null {
|
||||
const match = entry.match(/@(.+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||
import { parseJsonc } from "../../shared"
|
||||
import type { ConfigMergeResult, InstallConfig } from "../types"
|
||||
import { backupConfigFile } from "./backup-config"
|
||||
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
||||
import { deepMergeRecord } from "./deep-merge-record"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
@@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(omoConfigPath)) {
|
||||
const backupResult = backupConfigFile(omoConfigPath)
|
||||
if (!backupResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: omoConfigPath,
|
||||
error: `Failed to create backup: ${backupResult.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(omoConfigPath)
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EXIT_CODES = {
|
||||
FAILURE: 1,
|
||||
} as const
|
||||
|
||||
export const MIN_OPENCODE_VERSION = "1.0.150"
|
||||
export const MIN_OPENCODE_VERSION = "1.4.0"
|
||||
|
||||
export const PACKAGE_NAME = PLUGIN_NAME
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("install CLI - binary check behavior", () => {
|
||||
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
|
||||
// given OpenCode binary IS installed
|
||||
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
|
||||
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200")
|
||||
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
|
||||
|
||||
// given mock npm fetch
|
||||
globalThis.fetch = mock(() =>
|
||||
@@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => {
|
||||
// then should have printed success (OK symbol)
|
||||
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
|
||||
expect(allCalls).toContain("[OK]")
|
||||
expect(allCalls).toContain("OpenCode 1.0.200")
|
||||
expect(allCalls).toContain("OpenCode 1.4.0")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MIN_OPENCODE_VERSION } from "./doctor/constants"
|
||||
import { compareVersions } from "../shared/opencode-version"
|
||||
|
||||
export function getUnsupportedOpenCodeVersionMessage(openCodeVersion: string | null): string | null {
|
||||
if (!openCodeVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersions(openCodeVersion, MIN_OPENCODE_VERSION) >= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `Detected OpenCode ${openCodeVersion}, but ${MIN_OPENCODE_VERSION}+ is required. Update OpenCode, then rerun the installer.`
|
||||
}
|
||||
@@ -549,6 +549,58 @@ describe("generateModelConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("special-case agents include fallback_models", () => {
|
||||
test("explore includes fallback_models when Copilot and Claude are both available", () => {
|
||||
// #given both Copilot and Claude are available
|
||||
const config = createConfig({ hasCopilot: true, hasClaude: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should have fallback_models from the remaining chain entries
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(result.agents?.explore?.fallback_models).toBeDefined()
|
||||
expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("explore omits fallback_models when only one provider matches chain entries", () => {
|
||||
// #given only Claude is available
|
||||
const config = createConfig({ hasClaude: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should not have fallback_models (only one chain entry matches)
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(result.agents?.explore?.fallback_models).toBeUndefined()
|
||||
})
|
||||
|
||||
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
|
||||
// #given opencode-go and Claude are available
|
||||
const config = createConfig({ hasOpencodeGo: true, hasClaude: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then librarian should have fallback_models
|
||||
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
|
||||
expect(result.agents?.librarian?.fallback_models).toBeDefined()
|
||||
expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("librarian omits fallback_models when only one provider matches", () => {
|
||||
// #given only opencode-go is available
|
||||
const config = createConfig({ hasOpencodeGo: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then librarian should not have fallback_models
|
||||
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
|
||||
expect(result.agents?.librarian?.fallback_models).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("schema URL", () => {
|
||||
test("always includes correct schema URL", () => {
|
||||
// #given any config
|
||||
|
||||
+41
-11
@@ -37,22 +37,29 @@ function toFallbackModelObject(entry: FallbackEntry, provider: string): Fallback
|
||||
}
|
||||
}
|
||||
|
||||
function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
|
||||
config: T,
|
||||
function collectAvailableFallbacks(
|
||||
fallbackChain: FallbackEntry[],
|
||||
availability: ReturnType<typeof toProviderAvailability>,
|
||||
): T {
|
||||
): FallbackModelObject[] {
|
||||
const expandedFallbacks = fallbackChain.flatMap((entry) =>
|
||||
entry.providers
|
||||
.filter((provider) => isProviderAvailable(provider, availability))
|
||||
.map((provider) => toFallbackModelObject(entry, provider))
|
||||
)
|
||||
const uniqueFallbacks = expandedFallbacks.filter((entry, index, allEntries) =>
|
||||
return expandedFallbacks.filter((entry, index, allEntries) =>
|
||||
allEntries.findIndex((candidate) =>
|
||||
candidate.model === entry.model &&
|
||||
candidate.variant === entry.variant
|
||||
) === index
|
||||
)
|
||||
}
|
||||
|
||||
function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
|
||||
config: T,
|
||||
fallbackChain: FallbackEntry[],
|
||||
availability: ReturnType<typeof toProviderAvailability>,
|
||||
): T {
|
||||
const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability)
|
||||
const primaryIndex = uniqueFallbacks.findIndex((entry) => entry.model === config.model)
|
||||
if (primaryIndex === -1) {
|
||||
return config
|
||||
@@ -69,6 +76,23 @@ function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
|
||||
}
|
||||
}
|
||||
|
||||
function attachAllFallbackModels<T extends AgentConfig | CategoryConfig>(
|
||||
config: T,
|
||||
fallbackChain: FallbackEntry[],
|
||||
availability: ReturnType<typeof toProviderAvailability>,
|
||||
): T {
|
||||
const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability)
|
||||
const fallbackModels = uniqueFallbacks.filter((entry) => entry.model !== config.model)
|
||||
if (fallbackModels.length === 0) {
|
||||
return config
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
fallback_models: fallbackModels,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
@@ -101,26 +125,32 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
|
||||
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
|
||||
if (role === "librarian") {
|
||||
let agentConfig: AgentConfig | undefined
|
||||
if (avail.opencodeGo) {
|
||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.zai) {
|
||||
agents[role] = { model: ZAI_MODEL }
|
||||
agentConfig = { model: ZAI_MODEL }
|
||||
}
|
||||
if (agentConfig) {
|
||||
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "explore") {
|
||||
let agentConfig: AgentConfig
|
||||
if (avail.native.claude) {
|
||||
agents[role] = { model: "anthropic/claude-haiku-4-5" }
|
||||
agentConfig = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeZen) {
|
||||
agents[role] = { model: "opencode/claude-haiku-4-5" }
|
||||
agentConfig = { model: "opencode/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeGo) {
|
||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.copilot) {
|
||||
agents[role] = { model: "github-copilot/gpt-5-mini" }
|
||||
agentConfig = { model: "github-copilot/gpt-5-mini" }
|
||||
} else {
|
||||
agents[role] = { model: "opencode/gpt-5-nano" }
|
||||
agentConfig = { model: "opencode/gpt-5-nano" }
|
||||
}
|
||||
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import * as p from "@clack/prompts"
|
||||
import * as configManager from "./config-manager"
|
||||
import * as tuiInstallPrompts from "./tui-install-prompts"
|
||||
import { runTuiInstaller } from "./tui-installer"
|
||||
|
||||
function createMockSpinner(): ReturnType<typeof p.spinner> {
|
||||
return {
|
||||
start: () => undefined,
|
||||
stop: () => undefined,
|
||||
message: () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe("runTuiInstaller", () => {
|
||||
const originalIsStdinTty = process.stdin.isTTY
|
||||
const originalIsStdoutTty = process.stdout.isTTY
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
|
||||
})
|
||||
|
||||
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||
// given
|
||||
const restoreSpies = [
|
||||
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
|
||||
spyOn(p, "intro").mockImplementation(() => undefined),
|
||||
spyOn(p.log, "warn").mockImplementation(() => undefined),
|
||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||
]
|
||||
const promptSpy = spyOn(tuiInstallPrompts, "promptInstallConfig")
|
||||
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
|
||||
const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined)
|
||||
|
||||
// when
|
||||
const result = await runTuiInstaller({ tui: true }, "3.16.0")
|
||||
|
||||
// then
|
||||
expect(result).toBe(1)
|
||||
expect(promptSpy).not.toHaveBeenCalled()
|
||||
expect(addPluginSpy).not.toHaveBeenCalled()
|
||||
expect(outroSpy).toHaveBeenCalled()
|
||||
|
||||
for (const spy of restoreSpies) {
|
||||
spy.mockRestore()
|
||||
}
|
||||
promptSpy.mockRestore()
|
||||
addPluginSpy.mockRestore()
|
||||
outroSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("proceeds when OpenCode meets the minimum version", async () => {
|
||||
// given
|
||||
const restoreSpies = [
|
||||
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
|
||||
spyOn(p, "intro").mockImplementation(() => undefined),
|
||||
spyOn(p.log, "info").mockImplementation(() => undefined),
|
||||
spyOn(p.log, "warn").mockImplementation(() => undefined),
|
||||
spyOn(p.log, "success").mockImplementation(() => undefined),
|
||||
spyOn(p.log, "message").mockImplementation(() => undefined),
|
||||
spyOn(p, "note").mockImplementation(() => undefined),
|
||||
spyOn(p, "outro").mockImplementation(() => undefined),
|
||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||
success: true,
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
}),
|
||||
spyOn(configManager, "writeOmoConfig").mockReturnValue({
|
||||
success: true,
|
||||
configPath: "/tmp/oh-my-opencode.jsonc",
|
||||
}),
|
||||
]
|
||||
|
||||
// when
|
||||
const result = await runTuiInstaller({ tui: true }, "3.16.0")
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
|
||||
for (const spy of restoreSpies) {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeOmoConfig,
|
||||
} from "./config-manager"
|
||||
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||
import { promptInstallConfig } from "./tui-install-prompts"
|
||||
|
||||
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||
@@ -39,6 +40,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
|
||||
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
|
||||
} else {
|
||||
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
|
||||
|
||||
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||
if (unsupportedVersionMessage) {
|
||||
p.log.warn(unsupportedVersionMessage)
|
||||
p.outro(color.red("Installation blocked."))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const config = await promptInstallConfig(detected)
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ConfigMergeResult {
|
||||
|
||||
export interface DetectedConfig {
|
||||
isInstalled: boolean
|
||||
installedVersion: string | null
|
||||
hasClaude: boolean
|
||||
isMax20: boolean
|
||||
hasOpenAI: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { PART_STORAGE } from "../../shared"
|
||||
|
||||
describe("isCompactionAgent", () => {
|
||||
describe("#given agent name variations", () => {
|
||||
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
|
||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||
})
|
||||
|
||||
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("skips JSON messages whose part storage contains a compaction marker", () => {
|
||||
// given
|
||||
const compactionMessageID = "msg_test_background_compaction_marker"
|
||||
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
|
||||
id: compactionMessageID,
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}))
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
|
||||
id: "msg_001",
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}))
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("falls back to partial agent/model match", () => {
|
||||
// given
|
||||
const messageWithAgentOnly = {
|
||||
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("skips SDK messages that only exist to mark compaction", () => {
|
||||
// given
|
||||
const messages = [
|
||||
{
|
||||
id: "msg_compaction",
|
||||
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } },
|
||||
parts: [{ type: "compaction" }],
|
||||
},
|
||||
{ info: { agent: "sisyphus" } },
|
||||
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
|
||||
{ info: { tools: { bash: true } } },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = resolvePromptContextFromSessionMessages(messages)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { StoredMessage } from "../hook-message-injector"
|
||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import {
|
||||
hasCompactionPartInStorage,
|
||||
isCompactionAgent,
|
||||
isCompactionMessage,
|
||||
} from "../../shared/compaction-marker"
|
||||
|
||||
export { isCompactionAgent } from "../../shared/compaction-marker"
|
||||
|
||||
type SessionMessage = {
|
||||
id?: string
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
@@ -15,10 +23,7 @@ type SessionMessage = {
|
||||
modelID?: string
|
||||
tools?: StoredMessage["tools"]
|
||||
}
|
||||
}
|
||||
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
parts?: Array<{ type?: string }>
|
||||
}
|
||||
|
||||
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
||||
}
|
||||
|
||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||
if (isCompactionMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = message.info
|
||||
if (!info) {
|
||||
return null
|
||||
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content) as StoredMessage)
|
||||
const parsed = JSON.parse(content) as StoredMessage & { id?: string }
|
||||
if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) {
|
||||
continue
|
||||
}
|
||||
messages.push(parsed)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map<string, number
|
||||
return (manager as unknown as { rootDescendantCounts: Map<string, number> }).rootDescendantCounts
|
||||
}
|
||||
|
||||
function getPreStartDescendantReservations(manager: BackgroundManager): Set<string> {
|
||||
return (manager as unknown as { preStartDescendantReservations: Set<string> }).preStartDescendantReservations
|
||||
}
|
||||
|
||||
function getQueuesByKey(
|
||||
manager: BackgroundManager
|
||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||
@@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
messages: async () => ({
|
||||
data: [{
|
||||
info: {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
@@ -1177,6 +1192,101 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push({ body: args.body })
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({
|
||||
data: [{
|
||||
info: {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-parent-variant-wins",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task with mismatched variant",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.variant).toBe("max")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should not include variant in promptAsync body when task has no variant", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push({ body: args.body })
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-variant",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without variant",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.variant).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||
// given
|
||||
@@ -1431,6 +1541,47 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should mark task as error when startTask throws after session creation", async () => {
|
||||
//#given - startTask creates session but fails before sending prompt
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-zombie-session",
|
||||
sessionID: "session-zombie-placeholder",
|
||||
parentSessionID: "parent-zombie",
|
||||
status: "pending",
|
||||
agent: "explore",
|
||||
})
|
||||
delete (task as Partial<BackgroundTask>).sessionID
|
||||
|
||||
const input = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
|
||||
;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }).startTask = async (item) => {
|
||||
item.task.status = "running"
|
||||
item.task.sessionID = "ses_zombie_child"
|
||||
item.task.startedAt = new Date()
|
||||
item.task.concurrencyKey = concurrencyKey
|
||||
throw new Error("crash between session creation and prompt send")
|
||||
}
|
||||
|
||||
//#when
|
||||
await processKeyForTest(manager, concurrencyKey)
|
||||
|
||||
//#then - task must be marked as error, not left in running zombie state
|
||||
expect(task.status).toBe("error")
|
||||
expect(task.error).toContain("crash between session creation and prompt send")
|
||||
expect(task.completedAt).toBeDefined()
|
||||
})
|
||||
|
||||
test("should release queue slot when queued task is already interrupt", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
@@ -1739,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||
temperature: 0.25,
|
||||
topP: 0.55,
|
||||
maxOutputTokens: 8192,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -2339,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(retryTask.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("should only roll back the failed task reservation once when siblings still exist", async () => {
|
||||
// given
|
||||
const concurrencyKey = "test-agent"
|
||||
const task = createMockTask({
|
||||
id: "task-single-reservation-rollback",
|
||||
sessionID: "session-single-reservation-rollback",
|
||||
parentSessionID: "session-root",
|
||||
status: "pending",
|
||||
agent: "test-agent",
|
||||
rootSessionID: "session-root",
|
||||
})
|
||||
delete (task as Partial<BackgroundTask>).sessionID
|
||||
|
||||
const input = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
getRootDescendantCounts(manager).set("session-root", 2)
|
||||
getPreStartDescendantReservations(manager).add(task.id)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
;(manager as unknown as {
|
||||
startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void>
|
||||
}).startTask = async () => {
|
||||
throw new Error("session create failed")
|
||||
}
|
||||
|
||||
// when
|
||||
await processKeyForTest(manager, concurrencyKey)
|
||||
|
||||
// then
|
||||
expect(getRootDescendantCounts(manager).get("session-root")).toBe(1)
|
||||
})
|
||||
|
||||
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
||||
// given
|
||||
const firstSessionID = "ses-first-cancelled-during-create"
|
||||
|
||||
@@ -371,7 +371,7 @@ export class BackgroundManager {
|
||||
this.markPreStartDescendantReservation(task)
|
||||
|
||||
// Trigger processing (fire-and-forget)
|
||||
this.processKey(key)
|
||||
void this.processKey(key)
|
||||
|
||||
return { ...task }
|
||||
} catch (error) {
|
||||
@@ -408,12 +408,31 @@ export class BackgroundManager {
|
||||
} catch (error) {
|
||||
log("[background-agent] Error starting task:", error)
|
||||
this.rollbackPreStartDescendantReservation(item.task)
|
||||
|
||||
// Mark task as error so the parent polling loop detects the failure
|
||||
// instead of leaving it in a zombie "running" state with no prompt sent
|
||||
item.task.status = "error"
|
||||
item.task.error = error instanceof Error ? error.message : String(error)
|
||||
item.task.completedAt = new Date()
|
||||
|
||||
if (item.task.concurrencyKey) {
|
||||
this.concurrencyManager.release(item.task.concurrencyKey)
|
||||
item.task.concurrencyKey = undefined
|
||||
} else {
|
||||
this.concurrencyManager.release(key)
|
||||
}
|
||||
|
||||
removeTaskToastTracking(item.task.id)
|
||||
|
||||
// Abort the orphaned session if one was created before the error
|
||||
if (item.task.sessionID) {
|
||||
await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup")
|
||||
}
|
||||
|
||||
this.markForNotification(item.task)
|
||||
this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => {
|
||||
log("[background-agent] Failed to notify on startTask error:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1760,6 +1779,7 @@ export class BackgroundManager {
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
let tools: Record<string, boolean> | undefined = task.parentTools
|
||||
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
|
||||
|
||||
if (this.enableParentSessionNotifications) {
|
||||
try {
|
||||
@@ -1773,7 +1793,7 @@ export class BackgroundManager {
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}>)
|
||||
const promptContext = resolvePromptContextFromSessionMessages(
|
||||
promptContext = resolvePromptContextFromSessionMessages(
|
||||
messages,
|
||||
task.parentSessionID,
|
||||
)
|
||||
@@ -1817,6 +1837,8 @@ export class BackgroundManager {
|
||||
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
||||
const shouldReply = allComplete || isTaskFailure
|
||||
|
||||
const variant = promptContext?.model?.variant
|
||||
|
||||
try {
|
||||
await this.client.session.promptAsync({
|
||||
path: { id: task.parentSessionID },
|
||||
@@ -1824,6 +1846,7 @@ export class BackgroundManager {
|
||||
noReply: !shouldReply,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(variant !== undefined ? { variant } : {}),
|
||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
|
||||
@@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
expect(getSessionPromptParams("session-123")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
})
|
||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("strips leading zwsp from prompt body agent before promptAsync", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body?: { agent?: string } }> = []
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||
create: async () => ({ data: { id: "ses_child_clean_agent" } }),
|
||||
promptAsync: async (args?: { body?: { agent?: string } }) => {
|
||||
promptCalls.push(args ?? {})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const task = createTask({
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "\u200Bsisyphus-junior",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
})
|
||||
|
||||
const item = {
|
||||
task,
|
||||
input: {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
},
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
client,
|
||||
directory: "/fallback",
|
||||
concurrencyManager: { release: () => {} },
|
||||
tmuxEnabled: false,
|
||||
onTaskError: () => {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await startTask(item as any, ctx as any)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export const FALLBACK_AGENT = "general"
|
||||
@@ -168,11 +169,12 @@ export async function startTask(
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
|
||||
const promptBody = {
|
||||
agent: input.agent,
|
||||
agent: normalizedAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
@@ -180,7 +182,7 @@ export async function startTask(
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
...getAgentToolRestrictions(normalizedAgent),
|
||||
},
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
@@ -433,63 +433,89 @@ describe("boulder-state", () => {
|
||||
})
|
||||
|
||||
describe("getPlanProgress", () => {
|
||||
test("should count completed and uncompleted checkboxes", () => {
|
||||
// given - plan file with checkboxes
|
||||
test("should count only top-level tasks under TODOs and Final Verification Wave sections", () => {
|
||||
// given - plan with top-level tasks in tracked sections
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [ ] Task 1
|
||||
- [x] Task 2
|
||||
- [ ] Task 3
|
||||
- [X] Task 4
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Task 1
|
||||
- [x] 2. Task 2
|
||||
- [ ] 3. Task 3
|
||||
- [X] 4. Task 4
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(4)
|
||||
expect(progress.total).toBe(5)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should count space-indented unchecked checkbox", () => {
|
||||
// given - plan file with a two-space indented checkbox
|
||||
const planPath = join(TEST_DIR, "space-indented-plan.md")
|
||||
test("should ignore nested Acceptance Criteria checkboxes under TODOs (issue #3066)", () => {
|
||||
// given - plan with 9 completed top-level tasks and unchecked nested acceptance criteria
|
||||
const planPath = join(TEST_DIR, "issue-3066-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [ ] indented task
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implement feature A
|
||||
|
||||
**Acceptance Criteria**
|
||||
- [ ] criterion 1
|
||||
- [ ] criterion 2
|
||||
|
||||
- [x] 2. Implement feature B
|
||||
|
||||
**Acceptance Criteria**
|
||||
- [ ] criterion 3
|
||||
- [ ] criterion 4
|
||||
|
||||
- [x] 3. Implement feature C
|
||||
- [x] 4. Implement feature D
|
||||
- [x] 5. Implement feature E
|
||||
- [x] 6. Implement feature F
|
||||
- [x] 7. Implement feature G
|
||||
- [x] 8. Implement feature H
|
||||
- [x] 9. Implement feature I
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(1)
|
||||
expect(progress.completed).toBe(0)
|
||||
expect(progress.total).toBe(10)
|
||||
expect(progress.completed).toBe(9)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should count tab-indented unchecked checkbox", () => {
|
||||
// given - plan file with a tab-indented checkbox
|
||||
const planPath = join(TEST_DIR, "tab-indented-plan.md")
|
||||
test("should ignore checkboxes outside TODOs and Final Verification Wave sections", () => {
|
||||
// given - plan with checkboxes in Work Objectives, Success Criteria, and other sections
|
||||
const planPath = join(TEST_DIR, "ignore-other-sections-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [ ] tab-indented task
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
## Work Objectives
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(1)
|
||||
expect(progress.completed).toBe(0)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
### Definition of Done
|
||||
- [ ] Verifiable condition with command
|
||||
|
||||
test("should count mixed top-level checked and indented unchecked checkboxes", () => {
|
||||
// given - plan file with checked top-level and unchecked indented task
|
||||
const planPath = join(TEST_DIR, "mixed-indented-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [x] top-level completed task
|
||||
- [ ] nested unchecked task
|
||||
## TODOs
|
||||
- [x] 1. Real task one
|
||||
- [ ] 2. Real task two
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Final Checklist
|
||||
- [ ] All Must Have present
|
||||
- [ ] All Must NOT Have absent
|
||||
- [ ] All tests pass
|
||||
`)
|
||||
|
||||
// when
|
||||
@@ -501,11 +527,14 @@ describe("boulder-state", () => {
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should count space-indented completed checkbox", () => {
|
||||
// given - plan file with a two-space indented completed checkbox
|
||||
const planPath = join(TEST_DIR, "indented-completed-plan.md")
|
||||
test("should ignore indented checkboxes under top-level tasks", () => {
|
||||
// given - plan with indented unchecked nested checkboxes
|
||||
const planPath = join(TEST_DIR, "nested-indented-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [x] indented completed task
|
||||
|
||||
## TODOs
|
||||
- [x] 1. top-level completed task
|
||||
- [ ] nested unchecked task
|
||||
`)
|
||||
|
||||
// when
|
||||
@@ -517,20 +546,67 @@ describe("boulder-state", () => {
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should return isComplete true when all checked", () => {
|
||||
// given - all tasks completed
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
test("should require proper task label format in TODOs", () => {
|
||||
// given - plan with malformed labels (no numeric prefix)
|
||||
const planPath = join(TEST_DIR, "malformed-labels-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [x] Task 1
|
||||
- [X] Task 2
|
||||
|
||||
## TODOs
|
||||
- [ ] no number prefix
|
||||
- [x] 1. Valid numbered task
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.total).toBe(1)
|
||||
expect(progress.completed).toBe(1)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should require F-prefix label format in Final Verification Wave", () => {
|
||||
// given - plan with malformed final-wave labels
|
||||
const planPath = join(TEST_DIR, "malformed-final-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implementation done
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] missing F-prefix
|
||||
- [ ] F1. Proper final review
|
||||
- [x] F2. Another final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(3)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should return isComplete true when all top-level tasks checked", () => {
|
||||
// given - all top-level tasks completed
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Task 1
|
||||
- [X] 2. Task 2
|
||||
|
||||
## Final Verification Wave
|
||||
- [x] F1. Final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(3)
|
||||
expect(progress.completed).toBe(3)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
@@ -555,6 +631,84 @@ describe("boulder-state", () => {
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should support asterisk bullet top-level tasks", () => {
|
||||
// given - plan with asterisk bullet tasks
|
||||
const planPath = join(TEST_DIR, "asterisk-bullet-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
* [x] 1. Task using asterisk bullet
|
||||
* [ ] 2. Another asterisk task
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.completed).toBe(1)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should count only top-level checkboxes for simple plans with nested tasks", () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "simple-nested-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
- [ ] Top-level task 1
|
||||
- [x] Nested task ignored
|
||||
- [x] Top-level task 2
|
||||
* [ ] Another nested task ignored
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.completed).toBe(1)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should treat final-wave-only plans as structured mode", () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "final-wave-only-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Top-level final review
|
||||
- [x] Nested verification detail ignored
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(1)
|
||||
expect(progress.completed).toBe(0)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should ignore mixed indentation levels in simple plans", () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "simple-mixed-indentation-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
* [x] Top-level star task
|
||||
- [ ] Indented task ignored
|
||||
- [x] Tab-indented task ignored
|
||||
- [ ] Top-level dash task
|
||||
`)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.completed).toBe(1)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPlanName", () => {
|
||||
|
||||
@@ -196,8 +196,25 @@ export function findPrometheusPlans(directory: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^\d+\.\s+/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i
|
||||
|
||||
type ProgressSection = "todo" | "final-wave" | "other"
|
||||
|
||||
/**
|
||||
* Parse a plan file and count checkbox progress.
|
||||
*
|
||||
* Only top-level (zero-indent) checkboxes under `## TODOs` and
|
||||
* `## Final Verification Wave` sections are counted. The checkbox
|
||||
* body must carry a valid task label (`N.` for TODOs, `FN.` for
|
||||
* Final Verification Wave). Nested acceptance-criteria checkboxes
|
||||
* and checkboxes in other sections are intentionally ignored so
|
||||
* that progress tracking stays aligned with `readCurrentTopLevelTask`.
|
||||
*/
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (!existsSync(planPath)) {
|
||||
@@ -206,24 +223,89 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
|
||||
// Match markdown checkboxes: - [ ] or - [x] or - [X]
|
||||
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
|
||||
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
const total = uncheckedMatches.length + checkedMatches.length
|
||||
const completed = checkedMatches.length
|
||||
// Check if the plan has structured sections (## TODOs / ## Final Verification Wave)
|
||||
const hasStructuredSections = lines.some(
|
||||
(line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line),
|
||||
)
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
isComplete: total > 0 && completed === total,
|
||||
if (hasStructuredSections) {
|
||||
// Structured plan: only count top-level checkboxes with numbered labels
|
||||
// under ## TODOs and ## Final Verification Wave sections
|
||||
return getStructuredPlanProgress(lines)
|
||||
}
|
||||
|
||||
// Simple plan: count all top-level checkboxes anywhere
|
||||
return getSimplePlanProgress(content)
|
||||
} catch {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
}
|
||||
}
|
||||
|
||||
function getStructuredPlanProgress(lines: string[]): PlanProgress {
|
||||
let section: ProgressSection = "other"
|
||||
let total = 0
|
||||
let completed = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
|
||||
const uncheckedMatch = checkedMatch ? null : line.match(UNCHECKED_CHECKBOX_PATTERN)
|
||||
const match = checkedMatch ?? uncheckedMatch
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (match[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskBody = match[2].trim()
|
||||
const labelPattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN
|
||||
if (!labelPattern.test(taskBody)) {
|
||||
continue
|
||||
}
|
||||
|
||||
total++
|
||||
if (checkedMatch) {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
isComplete: total > 0 && completed === total,
|
||||
}
|
||||
}
|
||||
|
||||
function getSimplePlanProgress(content: string): PlanProgress {
|
||||
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
|
||||
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
|
||||
|
||||
const total = uncheckedMatches.length + checkedMatches.length
|
||||
const completed = checkedMatches.length
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
isComplete: total > 0 && completed === total,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan name from file path.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ULW_LOOP_TEMPLATE } from "./ralph-loop"
|
||||
|
||||
describe("ULW_LOOP_TEMPLATE", () => {
|
||||
test("returns the documented iteration caps for ultrawork and normal modes", () => {
|
||||
// given
|
||||
const expectedIterationCaps = "The iteration limit is 500 for ultrawork mode, 100 for normal mode"
|
||||
|
||||
// when
|
||||
const template = ULW_LOOP_TEMPLATE
|
||||
|
||||
// then
|
||||
expect(template).toContain(expectedIterationCaps)
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export const ULW_LOOP_TEMPLATE = `You are starting an ULTRAWORK Loop - a self-re
|
||||
2. When you believe the work is complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
|
||||
3. That does NOT finish the loop yet. The system will require Oracle verification
|
||||
4. The loop only ends after the system confirms Oracle verified the result
|
||||
5. There is no iteration limit
|
||||
5. The iteration limit is 500 for ultrawork mode, 100 for normal mode
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -120,6 +120,32 @@ describe("expandEnvVars", () => {
|
||||
expect(expanded).toBe("user-approved")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a sensitive environment variable expanded in trusted mode", () => {
|
||||
it("#when expanding the value #then it returns the env value bypassing the allowlist", () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-trusted"
|
||||
|
||||
// when
|
||||
const expanded = expandEnvVars("${SLACK_USER_TOKEN}", { trusted: true })
|
||||
|
||||
// then
|
||||
expect(expanded).toBe("xoxp-trusted")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an unset env var expanded in trusted mode with a default", () => {
|
||||
it("#when expanding the value #then it returns the default value", () => {
|
||||
// given
|
||||
delete process.env.UNSET_TRUSTED_VAR
|
||||
|
||||
// when
|
||||
const expanded = expandEnvVars("${UNSET_TRUSTED_VAR:-fallback}", { trusted: true })
|
||||
|
||||
// then
|
||||
expect(expanded).toBe("fallback")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("expandEnvVarsInObject", () => {
|
||||
@@ -165,4 +191,69 @@ describe("expandEnvVarsInObject", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a trusted skill MCP config object with sensitive env vars", () => {
|
||||
it("#when expanding env vars in trusted mode #then it expands all referenced env vars", () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-trusted-token"
|
||||
process.env.HOME = "/Users/tester"
|
||||
|
||||
// when
|
||||
const expanded = expandEnvVarsInObject(
|
||||
{
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||
],
|
||||
env: {
|
||||
HOME_DIR: "${HOME}",
|
||||
},
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(expanded).toEqual({
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer xoxp-trusted-token",
|
||||
],
|
||||
env: {
|
||||
HOME_DIR: "/Users/tester",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("#when expanding a remote http skill MCP config in trusted mode #then it expands sensitive headers", () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-trusted-token"
|
||||
|
||||
// when
|
||||
const expanded = expandEnvVarsInObject(
|
||||
{
|
||||
url: "https://mcp.slack.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${SLACK_USER_TOKEN}",
|
||||
},
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(expanded).toEqual({
|
||||
url: "https://mcp.slack.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer xoxp-trusted-token",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
isSensitiveMcpEnvVar,
|
||||
} from "./configure-allowed-env-vars"
|
||||
|
||||
export function expandEnvVars(value: string): string {
|
||||
export interface ExpandEnvVarsOptions {
|
||||
trusted?: boolean
|
||||
}
|
||||
|
||||
export function expandEnvVars(value: string, options: ExpandEnvVarsOptions = {}): string {
|
||||
const { trusted = false } = options
|
||||
return value.replace(
|
||||
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
||||
(_, varName: string, defaultValue?: string) => {
|
||||
if (!isAllowedMcpEnvVar(varName)) {
|
||||
if (!trusted && !isAllowedMcpEnvVar(varName)) {
|
||||
const isSensitive = isSensitiveMcpEnvVar(varName)
|
||||
const reason = isSensitive ? "sensitive variable" : "not in allowlist"
|
||||
|
||||
@@ -29,16 +34,16 @@ export function expandEnvVars(value: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function expandEnvVarsInObject<T>(obj: T): T {
|
||||
export function expandEnvVarsInObject<T>(obj: T, options: ExpandEnvVarsOptions = {}): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === "string") return expandEnvVars(obj) as T
|
||||
if (typeof obj === "string") return expandEnvVars(obj, options) as T
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => expandEnvVarsInObject(item)) as T
|
||||
return obj.map((item) => expandEnvVarsInObject(item, options)) as T
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = expandEnvVarsInObject(value)
|
||||
result[key] = expandEnvVarsInObject(value, options)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { join } from "node:path"
|
||||
|
||||
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||
const temporaryDirectories: string[] = []
|
||||
const originalCwd = process.cwd()
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
|
||||
return directory
|
||||
}
|
||||
|
||||
function writeDatabase(pluginsHome: string, database: unknown): void {
|
||||
writeFileSync(join(pluginsHome, "installed_plugins.json"), JSON.stringify(database), "utf-8")
|
||||
}
|
||||
|
||||
function createInstallPath(prefix: string): string {
|
||||
return createTemporaryDirectory(prefix)
|
||||
}
|
||||
|
||||
describe("discoverInstalledPlugins", () => {
|
||||
beforeEach(() => {
|
||||
mock.module("../../shared/logger", () => ({
|
||||
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
|
||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||
}
|
||||
|
||||
if (process.cwd() !== originalCwd) {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
||||
})
|
||||
|
||||
describe("#given project-scoped entries in v1 format", () => {
|
||||
it("#when cwd matches projectPath #then the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-match-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"project-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("project-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd is a subdirectory of projectPath #then the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-sub-")
|
||||
const subdirectory = join(projectDirectory, "packages", "app")
|
||||
mkdirSync(subdirectory, { recursive: true })
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"sub-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(subdirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-sub`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("sub-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v1-other-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"outside-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when projectPath is missing #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"no-path-plugin@market": {
|
||||
scope: "project",
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-noproj`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when scope is user #then it always loads regardless of cwd", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const unrelatedDirectory = createTemporaryDirectory("omo-v1-unrelated-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"user-plugin@market": {
|
||||
scope: "user",
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(unrelatedDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-user`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("user-plugin")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given project and local scoped entries in v2 format", () => {
|
||||
it("#when cwd matches project-scoped projectPath #then it loads while non-matching entries are dropped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-project-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-other-")
|
||||
const matchingInstall = createInstallPath("omo-v2-match-install-")
|
||||
const missingInstall = createInstallPath("omo-v2-miss-install-")
|
||||
const userInstall = createInstallPath("omo-v2-user-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"matching-project@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath: matchingInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"other-project@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: otherDirectory,
|
||||
installPath: missingInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"global-user@market": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: userInstall,
|
||||
version: "2.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-mix`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
const names = discovered.plugins.map((plugin) => plugin.name).sort()
|
||||
expect(names).toEqual(["global-user", "matching-project"])
|
||||
})
|
||||
|
||||
it("#when scope is local and cwd matches projectPath #then it loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-local-match-")
|
||||
const installPath = createInstallPath("omo-v2-local-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"local-plugin@market": [
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("local-plugin")
|
||||
})
|
||||
|
||||
it("#when scope is local and cwd does not match projectPath #then it is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-local-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-local-other-")
|
||||
const installPath = createInstallPath("omo-v2-local-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"local-plugin@market": [
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when multiple installations are present #then only the first is considered and scope filtering still applies", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-multi-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-multi-other-")
|
||||
const primaryInstall = createInstallPath("omo-v2-multi-primary-")
|
||||
const secondaryInstall = createInstallPath("omo-v2-multi-secondary-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"multi-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: otherDirectory,
|
||||
installPath: primaryInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath: secondaryInstall,
|
||||
version: "2.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-multi`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then — existing behavior keeps only the first entry; with scope filter it is
|
||||
// (correctly) skipped because the first entry points at a different project.
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given project and local scoped entries in v3 flat-array format", () => {
|
||||
it("#when cwd matches projectPath #then projectPath flows through and the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v3-match-")
|
||||
const installPath = createInstallPath("omo-v3-install-")
|
||||
writeDatabase(pluginsHome, [
|
||||
{
|
||||
name: "v3-project-plugin",
|
||||
marketplace: "market",
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
])
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("v3-project-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v3-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v3-miss-other-")
|
||||
const installPath = createInstallPath("omo-v3-install-")
|
||||
writeDatabase(pluginsHome, [
|
||||
{
|
||||
name: "v3-skipped-plugin",
|
||||
marketplace: "market",
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "v3-user-plugin",
|
||||
marketplace: "market",
|
||||
scope: "user",
|
||||
installPath: createInstallPath("omo-v3-user-install-"),
|
||||
version: "2.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
])
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("v3-user-plugin")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given enabledPluginsOverride combined with scope filtering", () => {
|
||||
it("#when a project-scoped plugin is disabled via override #then it is still skipped even if cwd would match", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-enabled-proj-")
|
||||
const installPath = createInstallPath("omo-enabled-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"gated-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-off`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
enabledPluginsOverride: { "gated-plugin@market": false },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when a project-scoped plugin is enabled and cwd matches #then it loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-enabled-match-")
|
||||
const installPath = createInstallPath("omo-enabled-match-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"enabled-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-on`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
enabledPluginsOverride: { "enabled-plugin@market": true },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("enabled-plugin")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { homedir } from "os"
|
||||
import { basename, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||
import type {
|
||||
InstalledPluginsDatabase,
|
||||
InstalledPluginEntryV3,
|
||||
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
|
||||
installedAt: entry.lastUpdated,
|
||||
lastUpdated: entry.lastUpdated,
|
||||
gitCommitSha: entry.gitCommitSha,
|
||||
projectPath: entry.projectPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
||||
const cwd = process.cwd()
|
||||
|
||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||
if (!installation) continue
|
||||
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
||||
continue
|
||||
}
|
||||
|
||||
if (!shouldLoadPluginForCwd(installation, cwd)) {
|
||||
log(`Skipping ${installation.scope}-scoped plugin outside current cwd: ${pluginKey}`, {
|
||||
projectPath: installation.projectPath,
|
||||
cwd,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const { installPath, scope, version } = installation
|
||||
|
||||
if (!existsSync(installPath)) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
describe("shouldLoadPluginForCwd", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("#given a user-scoped plugin", () => {
|
||||
it("#when called with any cwd #then it loads", () => {
|
||||
//#given
|
||||
const installation = { scope: "user" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a managed-scoped plugin", () => {
|
||||
it("#when called with any cwd #then it loads", () => {
|
||||
//#given
|
||||
const installation = { scope: "managed" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin without projectPath", () => {
|
||||
it("#when called with any cwd #then it is skipped", () => {
|
||||
//#given
|
||||
const installation = { scope: "project" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin without projectPath", () => {
|
||||
it("#when called with any cwd #then it is skipped", () => {
|
||||
//#given
|
||||
const installation = { scope: "local" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with matching projectPath", () => {
|
||||
it("#when cwd exactly matches projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when cwd is a subdirectory of projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, join(projectDirectory, "packages", "app"))
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with non-matching projectPath", () => {
|
||||
it("#when cwd is unrelated #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when cwd is the parent of projectPath #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: join(projectDirectory, "nested"),
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin with matching projectPath", () => {
|
||||
it("#when cwd matches projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "local" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin with non-matching projectPath", () => {
|
||||
it("#when cwd is unrelated #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||
const installation = {
|
||||
scope: "local" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with a tilde-prefixed projectPath", () => {
|
||||
let fakeHome: string
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHome = createTemporaryDirectory("omo-home-")
|
||||
mock.module("node:os", () => ({
|
||||
homedir: () => fakeHome,
|
||||
tmpdir,
|
||||
}))
|
||||
mock.module("os", () => ({
|
||||
homedir: () => fakeHome,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
it("#when the expanded home matches cwd #then it loads", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-match`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~/workspace/proj-a",
|
||||
}
|
||||
const cwd = join(fakeHome, "workspace", "proj-a")
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, cwd)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when the expanded home does not match cwd #then it is skipped", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-mismatch`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~/workspace/proj-a",
|
||||
}
|
||||
const cwd = join(fakeHome, "workspace", "proj-b")
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, cwd)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when projectPath is exactly ~ and cwd equals fake home #then it loads", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-root`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, fakeHome)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { containsPath } from "../../shared/contains-path"
|
||||
import type { PluginInstallation } from "./types"
|
||||
|
||||
function expandTilde(inputPath: string): string {
|
||||
if (inputPath === "~") {
|
||||
return homedir()
|
||||
}
|
||||
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
||||
return join(homedir(), inputPath.slice(2))
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
export function shouldLoadPluginForCwd(
|
||||
installation: Pick<PluginInstallation, "scope" | "projectPath">,
|
||||
cwd: string = process.cwd(),
|
||||
): boolean {
|
||||
if (installation.scope !== "project" && installation.scope !== "local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!installation.projectPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
return containsPath(expandTilde(installation.projectPath), cwd)
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export interface PluginInstallation {
|
||||
lastUpdated: string
|
||||
gitCommitSha?: string
|
||||
isLocal?: boolean
|
||||
/**
|
||||
* Claude Code records this on project/local-scoped installations.
|
||||
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||
* Used to filter project/local plugins that do not belong to the current cwd.
|
||||
*/
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +57,11 @@ export interface InstalledPluginEntryV3 {
|
||||
installPath: string
|
||||
lastUpdated: string
|
||||
gitCommitSha?: string
|
||||
/**
|
||||
* Claude Code records this on project/local-scoped installations.
|
||||
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||
*/
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getMainSessionID,
|
||||
registerAgentName,
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
_resetForTesting,
|
||||
} from "./state"
|
||||
|
||||
@@ -28,7 +29,7 @@ describe("claude-code-session-state", () => {
|
||||
test("should store agent for session", () => {
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
const agent = "Prometheus (Planner)"
|
||||
const agent = "Prometheus - Plan Builder"
|
||||
|
||||
// when
|
||||
setSessionAgent(sessionID, agent)
|
||||
@@ -52,13 +53,13 @@ describe("claude-code-session-state", () => {
|
||||
test("should NOT overwrite existing agent (first-write wins)", () => {
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||
|
||||
// when - try to overwrite
|
||||
setSessionAgent(sessionID, "sisyphus")
|
||||
|
||||
// then - first agent preserved
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
|
||||
})
|
||||
|
||||
test("should return undefined for unknown session", () => {
|
||||
@@ -73,7 +74,7 @@ describe("claude-code-session-state", () => {
|
||||
test("should overwrite existing agent", () => {
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||
|
||||
// when - force update
|
||||
updateSessionAgent(sessionID, "sisyphus")
|
||||
@@ -99,8 +100,8 @@ describe("claude-code-session-state", () => {
|
||||
test("should remove agent from session", () => {
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
||||
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
|
||||
|
||||
// when
|
||||
clearSessionAgent(sessionID)
|
||||
@@ -140,6 +141,15 @@ describe("claude-code-session-state", () => {
|
||||
expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true)
|
||||
})
|
||||
|
||||
test("should resolve config keys back to the registered raw agent name", () => {
|
||||
// given
|
||||
registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||
|
||||
// when / then
|
||||
expect(resolveRegisteredAgentName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||
expect(resolveRegisteredAgentName("Atlas - Plan Executor")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
||||
})
|
||||
|
||||
describe("#given atlas display name with zero-width prefix", () => {
|
||||
describe("#when checking registration without the zero-width prefix", () => {
|
||||
test("#then it treats the display name as registered", () => {
|
||||
@@ -160,15 +170,15 @@ describe("claude-code-session-state", () => {
|
||||
test("should correctly identify Prometheus agent for permission checks", () => {
|
||||
// given - Prometheus session
|
||||
const sessionID = "test-prometheus-session"
|
||||
const prometheusAgent = "Prometheus (Planner)"
|
||||
const prometheusAgent = "Prometheus - Plan Builder"
|
||||
|
||||
// when - agent is set (simulating chat.message hook)
|
||||
setSessionAgent(sessionID, prometheusAgent)
|
||||
|
||||
// then - getSessionAgent returns correct agent for prometheus-md-only hook
|
||||
const agent = getSessionAgent(sessionID)
|
||||
expect(agent).toBe("Prometheus (Planner)")
|
||||
expect(["Prometheus (Planner)"].includes(agent!)).toBe(true)
|
||||
expect(agent).toBe("Prometheus - Plan Builder")
|
||||
expect(["Prometheus - Plan Builder"].includes(agent!)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return undefined when agent not set (bug scenario)", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
|
||||
}
|
||||
|
||||
const registeredAgentNames = new Set<string>()
|
||||
const registeredAgentAliases = new Map<string, string>()
|
||||
|
||||
const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
|
||||
|
||||
@@ -28,10 +29,16 @@ function normalizeStoredAgentName(name: string): string {
|
||||
export function registerAgentName(name: string): void {
|
||||
const normalizedName = normalizeRegisteredAgentName(name)
|
||||
registeredAgentNames.add(normalizedName)
|
||||
if (!registeredAgentAliases.has(normalizedName)) {
|
||||
registeredAgentAliases.set(normalizedName, name)
|
||||
}
|
||||
|
||||
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
|
||||
if (configKey !== normalizedName) {
|
||||
registeredAgentNames.add(configKey)
|
||||
if (!registeredAgentAliases.has(configKey)) {
|
||||
registeredAgentAliases.set(configKey, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +46,15 @@ export function isAgentRegistered(name: string): boolean {
|
||||
return registeredAgentNames.has(normalizeRegisteredAgentName(name))
|
||||
}
|
||||
|
||||
export function resolveRegisteredAgentName(name: string | undefined): string | undefined {
|
||||
if (typeof name !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedName = normalizeRegisteredAgentName(name)
|
||||
return registeredAgentAliases.get(normalizedName) ?? normalizeStoredAgentName(name)
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetForTesting(): void {
|
||||
_mainSessionID = undefined
|
||||
@@ -46,6 +62,7 @@ export function _resetForTesting(): void {
|
||||
syncSubagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
registeredAgentNames.clear()
|
||||
registeredAgentAliases.clear()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
generatePartId,
|
||||
injectHookMessage,
|
||||
} from "./injector"
|
||||
import { PART_STORAGE } from "../../shared"
|
||||
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
||||
|
||||
//#region Mocks
|
||||
@@ -53,6 +54,7 @@ function createMockClient(messages: Array<{
|
||||
tools?: Record<string, boolean>
|
||||
time?: { created?: number }
|
||||
}
|
||||
parts?: Array<{ type?: string }>
|
||||
}>): {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
||||
@@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
|
||||
expect(result?.agent).toBe("newest-by-time")
|
||||
})
|
||||
|
||||
it("skips compaction marker user messages when resolving nearest message", async () => {
|
||||
const mockClient = createMockClient([
|
||||
{
|
||||
id: "msg_compaction",
|
||||
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 200 } },
|
||||
parts: [{ type: "compaction" }],
|
||||
},
|
||||
{
|
||||
id: "msg_real",
|
||||
info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" }, time: { created: 100 } },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
||||
@@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => {
|
||||
|
||||
expect(result?.agent).toBe("newest-by-time")
|
||||
})
|
||||
|
||||
it("skips JSON messages whose parts contain a compaction marker", () => {
|
||||
mockIsSqliteBackend.mockReturnValue(false)
|
||||
const messageDir = createMessageDir()
|
||||
const compactionMessageID = "msg_test_injector_compaction_marker"
|
||||
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||
tempDirs.push(partDir)
|
||||
|
||||
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
|
||||
id: compactionMessageID,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
time: { created: 200 },
|
||||
}))
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||
|
||||
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
|
||||
id: "msg_0002",
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4" },
|
||||
time: { created: 100 },
|
||||
}))
|
||||
|
||||
const result = findNearestMessageWithFields(messageDir)
|
||||
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
@@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
expect(result).toBe("earliest-agent")
|
||||
})
|
||||
|
||||
it("skips compaction marker user messages when resolving first agent", async () => {
|
||||
const mockClient = createMockClient([
|
||||
{ id: "msg_compaction", info: { agent: "atlas", time: { created: 10 } }, parts: [{ type: "compaction" }] },
|
||||
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
|
||||
expect(result).toBe("sisyphus")
|
||||
})
|
||||
|
||||
it("skips messages without agent field", async () => {
|
||||
const mockClient = createMockClient([
|
||||
{ info: {} },
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
||||
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||
|
||||
export interface StoredMessage {
|
||||
agent?: string
|
||||
@@ -32,6 +33,7 @@ interface SDKMessage {
|
||||
created?: number
|
||||
}
|
||||
}
|
||||
parts?: Array<{ type?: string }>
|
||||
}
|
||||
|
||||
const processPrefix = randomBytes(4).toString("hex")
|
||||
@@ -39,6 +41,10 @@ let messageCounter = 0
|
||||
let partCounter = 0
|
||||
|
||||
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
||||
if (isCompactionMessage(msg)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = msg.info
|
||||
if (!info) return null
|
||||
|
||||
@@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
|
||||
return {
|
||||
fileName,
|
||||
msg,
|
||||
hasCompactionMarker: hasCompactionPartInStorage(
|
||||
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||
),
|
||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
||||
.filter((entry): entry is {
|
||||
fileName: string
|
||||
msg: StoredMessage & { time?: { created?: number } }
|
||||
hasCompactionMarker: boolean
|
||||
createdAt: number
|
||||
} => entry !== null)
|
||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
||||
|
||||
for (const entry of messages) {
|
||||
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
||||
return entry.msg
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of messages) {
|
||||
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
||||
return entry.msg
|
||||
}
|
||||
@@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
|
||||
return {
|
||||
fileName,
|
||||
msg,
|
||||
hasCompactionMarker: hasCompactionPartInStorage(
|
||||
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||
),
|
||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
||||
.filter((entry): entry is {
|
||||
fileName: string
|
||||
msg: StoredMessage & { time?: { created?: number } }
|
||||
hasCompactionMarker: boolean
|
||||
createdAt: number
|
||||
} => entry !== null)
|
||||
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
||||
|
||||
for (const entry of messages) {
|
||||
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.msg.agent) {
|
||||
return entry.msg.agent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
|
||||
/**
|
||||
* Per-server OAuth refresh mutex to prevent concurrent refresh race conditions.
|
||||
*
|
||||
* When multiple operations need to refresh a token for the same server,
|
||||
* this ensures only one refresh request is made and all waiters receive
|
||||
* the same result.
|
||||
*/
|
||||
|
||||
const ongoingRefreshes = new Map<string, Promise<OAuthTokenData>>()
|
||||
|
||||
/**
|
||||
* Execute a token refresh with per-server mutual exclusion.
|
||||
*
|
||||
* If a refresh is already in progress for the given server, this will
|
||||
* return the same promise to all concurrent callers. Once the refresh
|
||||
* completes (success or failure), the lock is released.
|
||||
*
|
||||
* @param serverUrl - The OAuth server URL (used as mutex key)
|
||||
* @param refreshFn - The actual refresh operation to execute
|
||||
* @returns Promise that resolves to the new token data
|
||||
*/
|
||||
export async function withRefreshMutex(
|
||||
serverUrl: string,
|
||||
refreshFn: () => Promise<OAuthTokenData>,
|
||||
): Promise<OAuthTokenData> {
|
||||
const existing = ongoingRefreshes.get(serverUrl)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const refreshPromise = refreshFn().finally(() => {
|
||||
ongoingRefreshes.delete(serverUrl)
|
||||
})
|
||||
|
||||
ongoingRefreshes.set(serverUrl, refreshPromise)
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a refresh is currently in progress for a server.
|
||||
*
|
||||
* @param serverUrl - The OAuth server URL
|
||||
* @returns true if a refresh operation is active
|
||||
*/
|
||||
export function isRefreshInProgress(serverUrl: string): boolean {
|
||||
return ongoingRefreshes.has(serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of servers currently undergoing token refresh.
|
||||
*
|
||||
* @returns Number of active refresh operations
|
||||
*/
|
||||
export function getActiveRefreshCount(): number {
|
||||
return ongoingRefreshes.size
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { getOpenCodeConfigDir } from "../../shared"
|
||||
|
||||
@@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||
chmodSync(filePath, 0o600)
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||
chmodSync(tempPath, 0o600)
|
||||
renameSync(tempPath, filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
|
||||
|
||||
const trackedStates: SkillMcpManagerState[] = []
|
||||
const createdStdioTransports: MockStdioClientTransport[] = []
|
||||
const createdHttpTransports: MockStreamableHTTPClientTransport[] = []
|
||||
|
||||
class MockClient {
|
||||
readonly close = mock(async () => {})
|
||||
|
||||
constructor(
|
||||
_clientInfo: { name: string; version: string },
|
||||
_options: { capabilities: Record<string, never> }
|
||||
) {}
|
||||
|
||||
async connect(_transport: unknown): Promise<void> {
|
||||
// Successful connect, env-related assertions happen on transport constructor args
|
||||
}
|
||||
}
|
||||
|
||||
class MockStdioClientTransport {
|
||||
readonly close = mock(async () => {})
|
||||
readonly options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }
|
||||
|
||||
constructor(options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }) {
|
||||
this.options = options
|
||||
createdStdioTransports.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
interface MockHttpTransportOptions {
|
||||
requestInit?: { headers?: Record<string, string> }
|
||||
}
|
||||
|
||||
class MockStreamableHTTPClientTransport {
|
||||
readonly close = mock(async () => {})
|
||||
readonly url: URL
|
||||
readonly options?: MockHttpTransportOptions
|
||||
|
||||
constructor(url: URL, options?: MockHttpTransportOptions) {
|
||||
this.url = url
|
||||
this.options = options
|
||||
createdHttpTransports.push(this)
|
||||
}
|
||||
|
||||
async start() {}
|
||||
}
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: MockClient,
|
||||
}))
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
||||
StdioClientTransport: MockStdioClientTransport,
|
||||
}))
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: MockStreamableHTTPClientTransport,
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
const { disconnectAll } = await import("./cleanup")
|
||||
const { getOrCreateClient } = await import("./connection")
|
||||
|
||||
function createState(): SkillMcpManagerState {
|
||||
const state: SkillMcpManagerState = {
|
||||
clients: new Map(),
|
||||
pendingConnections: new Map(),
|
||||
disconnectedSessions: new Map(),
|
||||
authProviders: new Map(),
|
||||
cleanupRegistered: false,
|
||||
cleanupInterval: null,
|
||||
cleanupHandlers: [],
|
||||
idleTimeoutMs: 5 * 60 * 1000,
|
||||
shutdownGeneration: 0,
|
||||
inFlightConnections: new Map(),
|
||||
disposed: false,
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => null,
|
||||
login: async () => ({ accessToken: "test-token" }),
|
||||
refresh: async () => ({ accessToken: "test-token" }),
|
||||
}),
|
||||
}
|
||||
trackedStates.push(state)
|
||||
return state
|
||||
}
|
||||
|
||||
function createClientInfo(
|
||||
serverName: string,
|
||||
scope?: SkillMcpClientInfo["scope"],
|
||||
): SkillMcpClientInfo {
|
||||
return {
|
||||
serverName,
|
||||
skillName: "env-skill",
|
||||
sessionID: "session-env",
|
||||
...(scope !== undefined ? { scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function createClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
createdStdioTransports.length = 0
|
||||
createdHttpTransports.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const state of trackedStates) {
|
||||
await disconnectAll(state)
|
||||
}
|
||||
trackedStates.length = 0
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
describe("getOrCreateClient env var expansion", () => {
|
||||
describe("#given a scope-sensitive stdio skill MCP config", () => {
|
||||
test.each([
|
||||
["opencode-project", "Authorization:Bearer "],
|
||||
["local", "Authorization:Bearer "],
|
||||
["user", "Authorization:Bearer xoxp-scope-token"],
|
||||
["builtin", "Authorization:Bearer xoxp-scope-token"],
|
||||
] satisfies Array<[NonNullable<SkillMcpClientInfo["scope"]>, string]>) (
|
||||
"#when creating the client for %s scope #then args expand to %s",
|
||||
async (scope, expectedAuthorizationHeader) => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-scope-token"
|
||||
const state = createState()
|
||||
const info = createClientInfo(`scope-${scope}`, scope)
|
||||
const clientKey = createClientKey(info)
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await getOrCreateClient({ state, clientKey, info, config })
|
||||
|
||||
// then
|
||||
expect(createdStdioTransports).toHaveLength(1)
|
||||
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(expectedAuthorizationHeader)
|
||||
},
|
||||
)
|
||||
|
||||
it("#when creating the client without scope #then env vars remain trusted for backward compatibility", async () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-undefined-scope-token"
|
||||
const state = createState()
|
||||
const info = createClientInfo("scope-undefined")
|
||||
const clientKey = createClientKey(info)
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await getOrCreateClient({ state, clientKey, info, config })
|
||||
|
||||
// then
|
||||
expect(createdStdioTransports).toHaveLength(1)
|
||||
expect(createdStdioTransports[0]?.options.args?.[4]).toBe(
|
||||
"Authorization:Bearer xoxp-undefined-scope-token",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a stdio skill MCP config with sensitive env vars in args", () => {
|
||||
it("#when creating the client #then sensitive env vars in args are expanded", async () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-secret-token"
|
||||
const state = createState()
|
||||
const info = createClientInfo("slack-stdio")
|
||||
const clientKey = createClientKey(info)
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer ${SLACK_USER_TOKEN}",
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await getOrCreateClient({ state, clientKey, info, config })
|
||||
|
||||
// then
|
||||
expect(createdStdioTransports).toHaveLength(1)
|
||||
expect(createdStdioTransports[0]?.options.args).toEqual([
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://mcp.slack.com/mcp",
|
||||
"--header",
|
||||
"Authorization:Bearer xoxp-secret-token",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a stdio skill MCP config with sensitive env vars in env map", () => {
|
||||
it("#when creating the client #then sensitive env vars in env map are expanded", async () => {
|
||||
// given
|
||||
process.env.MY_SLACK_USER_TOKEN_VALUE = "token-123"
|
||||
const state = createState()
|
||||
const info = createClientInfo("env-stdio")
|
||||
const clientKey = createClientKey(info)
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: {
|
||||
SLACK_BOT_USER_ID: "${MY_SLACK_USER_TOKEN_VALUE}",
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await getOrCreateClient({ state, clientKey, info, config })
|
||||
|
||||
// then
|
||||
expect(createdStdioTransports).toHaveLength(1)
|
||||
expect(createdStdioTransports[0]?.options.env?.SLACK_BOT_USER_ID).toBe("token-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an http skill MCP config with sensitive env vars in headers", () => {
|
||||
it("#when creating the client #then sensitive env vars in headers are expanded", async () => {
|
||||
// given
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-http-secret"
|
||||
const state = createState()
|
||||
const info = createClientInfo("slack-http")
|
||||
const clientKey = createClientKey(info)
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.slack.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${SLACK_USER_TOKEN}",
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await getOrCreateClient({ state, clientKey, info, config })
|
||||
|
||||
// then
|
||||
expect(createdHttpTransports).toHaveLength(1)
|
||||
expect(createdHttpTransports[0]?.options?.requestInit?.headers?.Authorization).toBe(
|
||||
"Bearer xoxp-http-secret"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,7 @@ function createClientInfo(sessionID: string): SkillMcpClientInfo {
|
||||
serverName: "race-server",
|
||||
skillName: "race-skill",
|
||||
sessionID,
|
||||
scope: "builtin",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"])
|
||||
|
||||
export async function getOrCreateClient(params: {
|
||||
state: SkillMcpManagerState
|
||||
clientKey: string
|
||||
@@ -38,7 +40,8 @@ export async function getOrCreateClient(params: {
|
||||
return pending
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
|
||||
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
|
||||
let currentConnectionPromise!: Promise<Client>
|
||||
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
||||
currentConnectionPromise = (async () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Redacts sensitive tokens from error messages to prevent credential exposure
|
||||
// Follows same patterns as env-cleaner.ts for consistency
|
||||
|
||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
// API keys and tokens in common formats
|
||||
/[a-zA-Z0-9_-]*(?:api[_-]?key|apikey)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||
/[a-zA-Z0-9_-]*(?:auth[_-]?token|authtoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||
/[a-zA-Z0-9_-]*(?:access[_-]?token|accesstoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||
/[a-zA-Z0-9_-]*(?:secret)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
|
||||
/[a-zA-Z0-9_-]*(?:password)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{8,})/gi,
|
||||
|
||||
// Bearer tokens
|
||||
/bearer\s+([a-zA-Z0-9_-]{20,})/gi,
|
||||
|
||||
// Common token prefixes
|
||||
/sk-[a-zA-Z0-9]{20,}/g, // OpenAI-style secret keys
|
||||
/gh[pousr]_[a-zA-Z0-9]{20,}/gi, // GitHub tokens
|
||||
/glpat-[a-zA-Z0-9_-]{20,}/gi, // GitLab tokens
|
||||
/[A-Za-z0-9_]{20,}-[A-Za-z0-9_]{10,}-[A-Za-z0-9_]{10,}/g, // Common JWT-like patterns
|
||||
]
|
||||
|
||||
const REDACTION_MARKER = "[REDACTED]"
|
||||
|
||||
/**
|
||||
* Redacts sensitive tokens from a string.
|
||||
* Used for error messages that may contain command-line arguments or environment info.
|
||||
*/
|
||||
export function redactSensitiveData(input: string): string {
|
||||
let result = input
|
||||
|
||||
for (const pattern of SENSITIVE_PATTERNS) {
|
||||
result = result.replace(pattern, REDACTION_MARKER)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive data from an Error object, returning a new Error.
|
||||
* Preserves the stack trace but redacts the message.
|
||||
*/
|
||||
export function redactErrorSensitiveData(error: Error): Error {
|
||||
const redactedMessage = redactSensitiveData(error.message)
|
||||
const redactedError = new Error(redactedMessage)
|
||||
redactedError.stack = error.stack ? redactSensitiveData(error.stack) : undefined
|
||||
return redactedError
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
const mockGetOrCreateClient = mock(async () => {
|
||||
throw new Error("not used")
|
||||
})
|
||||
|
||||
const mockGetOrCreateClientWithRetryImpl = mock(async () => ({
|
||||
callTool: mock(async () => ({ content: [{ type: "text", text: "unused" }] })),
|
||||
close: mock(async () => {}),
|
||||
}))
|
||||
|
||||
type ManagerModule = typeof import("./manager")
|
||||
|
||||
async function importFreshManagerModule(): Promise<ManagerModule> {
|
||||
mock.module("./connection", () => ({
|
||||
getOrCreateClient: mockGetOrCreateClient,
|
||||
getOrCreateClientWithRetryImpl: mockGetOrCreateClientWithRetryImpl,
|
||||
}))
|
||||
|
||||
mock.module("../mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {},
|
||||
}))
|
||||
|
||||
return await import(new URL(`./manager.ts?oauth-retry-test=${Date.now()}-${Math.random()}`, import.meta.url).href)
|
||||
}
|
||||
|
||||
function createInfo(): SkillMcpClientInfo {
|
||||
return {
|
||||
serverName: "oauth-server",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(): SkillMcpServerContext {
|
||||
return {
|
||||
skillName: "oauth-skill",
|
||||
config: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: { clientId: "test-client" },
|
||||
} satisfies ClaudeCodeMcpServer,
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("SkillMcpManager post-request OAuth retry", () => {
|
||||
beforeEach(() => {
|
||||
mockGetOrCreateClient.mockClear()
|
||||
mockGetOrCreateClientWithRetryImpl.mockClear()
|
||||
})
|
||||
|
||||
it("retries the operation after a 401 refresh succeeds", async () => {
|
||||
// given
|
||||
const { SkillMcpManager } = await importFreshManagerModule()
|
||||
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||
const manager = new SkillMcpManager({
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}),
|
||||
})
|
||||
const callTool = mock(async () => {
|
||||
if (callTool.mock.calls.length === 1) {
|
||||
throw new Error("401 Unauthorized")
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: "success" }] }
|
||||
})
|
||||
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||
|
||||
// when
|
||||
const result = await manager.callTool(createInfo(), createContext(), "test-tool", {})
|
||||
|
||||
// then
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("retries the operation after a 403 refresh succeeds without step-up scope", async () => {
|
||||
// given
|
||||
const { SkillMcpManager } = await importFreshManagerModule()
|
||||
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||
const manager = new SkillMcpManager({
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}),
|
||||
})
|
||||
const callTool = mock(async () => {
|
||||
if (callTool.mock.calls.length === 1) {
|
||||
throw new Error("403 Forbidden")
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: "success" }] }
|
||||
})
|
||||
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||
|
||||
// when
|
||||
const result = await manager.callTool(createInfo(), createContext(), "test-tool", {})
|
||||
|
||||
// then
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("propagates the auth error without retry when refresh fails", async () => {
|
||||
// given
|
||||
const { SkillMcpManager } = await importFreshManagerModule()
|
||||
const refresh = mock(async () => {
|
||||
throw new Error("refresh failed")
|
||||
})
|
||||
const manager = new SkillMcpManager({
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}),
|
||||
})
|
||||
const callTool = mock(async () => {
|
||||
throw new Error("401 Unauthorized")
|
||||
})
|
||||
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||
|
||||
// when / then
|
||||
await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized")
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("only attempts one refresh when the retried operation returns 401 again", async () => {
|
||||
// given
|
||||
const { SkillMcpManager } = await importFreshManagerModule()
|
||||
const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
||||
const manager = new SkillMcpManager({
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}),
|
||||
})
|
||||
const callTool = mock(async () => {
|
||||
throw new Error("401 Unauthorized")
|
||||
})
|
||||
mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) })
|
||||
|
||||
// when / then
|
||||
await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized")
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
@@ -80,6 +81,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
@@ -95,6 +97,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "custom-server",
|
||||
skillName: "custom-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
@@ -112,6 +115,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "http-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
@@ -130,6 +134,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "sse-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "sse",
|
||||
@@ -148,6 +153,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "inferred-http",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
@@ -165,6 +171,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "stdio-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
@@ -184,6 +191,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "inferred-stdio",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
@@ -202,6 +210,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "mixed-config",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
@@ -224,6 +233,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "bad-url-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
@@ -242,6 +252,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "http-error-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
@@ -259,6 +270,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "hint-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
@@ -276,6 +288,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "mock-test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
@@ -302,6 +315,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "missing-command",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
@@ -320,6 +334,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command-xyz",
|
||||
@@ -338,6 +353,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command",
|
||||
@@ -358,11 +374,13 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const session2Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-2",
|
||||
scope: "builtin",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -396,6 +414,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "signal-server",
|
||||
skillName: "signal-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
@@ -423,11 +442,12 @@ describe("SkillMcpManager", () => {
|
||||
describe("isConnected", () => {
|
||||
it("returns false for unconnected server", () => {
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "unknown",
|
||||
skillName: "test",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "$1",
|
||||
skillName: "$2",
|
||||
sessionID: "$3",
|
||||
scope: "builtin",
|
||||
}
|
||||
|
||||
// when / #then
|
||||
expect(manager.isConnected(info)).toBe(false)
|
||||
@@ -448,6 +468,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const configWithoutEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
@@ -471,6 +492,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-2",
|
||||
scope: "builtin",
|
||||
}
|
||||
const configWithEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
@@ -498,6 +520,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
@@ -526,6 +549,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "no-auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
@@ -546,6 +570,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "retry-server",
|
||||
skillName: "retry-skill",
|
||||
sessionID: "session-retry-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
@@ -584,6 +609,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "fail-server",
|
||||
skillName: "fail-skill",
|
||||
sessionID: "session-fail-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
@@ -615,6 +641,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "error-server",
|
||||
skillName: "error-skill",
|
||||
sessionID: "session-error-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
@@ -653,6 +680,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "oauth-server",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -679,6 +707,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "oauth-no-token",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-2",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -705,6 +734,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "oauth-with-headers",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-3",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -734,6 +764,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "oauth-refresh",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-refresh",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -766,6 +797,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "oauth-refresh-fallback",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-refresh-fallback",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -799,6 +831,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "no-oauth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-no-oauth",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -824,6 +857,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "stepup-server",
|
||||
skillName: "stepup-skill",
|
||||
sessionID: "session-stepup-1",
|
||||
scope: "builtin",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
@@ -869,6 +903,7 @@ describe("SkillMcpManager", () => {
|
||||
serverName: "no-stepup-server",
|
||||
skillName: "no-stepup-skill",
|
||||
sessionID: "session-no-stepup",
|
||||
scope: "builtin",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
|
||||
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
|
||||
import { handleStepUpIfNeeded } from "./oauth-handler"
|
||||
import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler"
|
||||
import type {
|
||||
OAuthProviderFactory,
|
||||
SkillMcpClientInfo,
|
||||
@@ -110,6 +110,7 @@ export class SkillMcpManager {
|
||||
): Promise<T> {
|
||||
const maxRetries = 3
|
||||
let lastError: Error | null = null
|
||||
const refreshAttempted = new Set<string>()
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
@@ -130,6 +131,17 @@ export class SkillMcpManager {
|
||||
continue
|
||||
}
|
||||
|
||||
const postRequestRefreshHandled = await handlePostRequestAuthError({
|
||||
error: lastError,
|
||||
config,
|
||||
authProviders: this.state.authProviders,
|
||||
createOAuthProvider: this.state.createOAuthProvider,
|
||||
refreshAttempted,
|
||||
})
|
||||
if (postRequestRefreshHandled) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!errorMessage.includes("not connected")) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
||||
|
||||
type OAuthHandlerModule = typeof import("./oauth-handler")
|
||||
|
||||
async function importFreshOAuthHandlerModule(): Promise<OAuthHandlerModule> {
|
||||
mock.module("../mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {},
|
||||
}))
|
||||
|
||||
return await import(new URL(`./oauth-handler.ts?oauth-handler-test=${Date.now()}-${Math.random()}`, import.meta.url).href)
|
||||
}
|
||||
|
||||
type Deferred<TValue> = {
|
||||
promise: Promise<TValue>
|
||||
resolve: (value: TValue) => void
|
||||
}
|
||||
|
||||
function createDeferred<TValue>(): Deferred<TValue> {
|
||||
let resolvePromise: ((value: TValue) => void) | null = null
|
||||
const promise = new Promise<TValue>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
if (!resolvePromise) {
|
||||
throw new Error("Failed to create deferred promise")
|
||||
}
|
||||
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
function createConfig(serverUrl: string): ClaudeCodeMcpServer {
|
||||
return {
|
||||
url: serverUrl,
|
||||
oauth: {
|
||||
clientId: "test-client",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("oauth-handler refresh mutex wiring", () => {
|
||||
it("deduplicates concurrent pre-request refresh attempts for the same server", async () => {
|
||||
// given
|
||||
const { buildHttpRequestInit } = await importFreshOAuthHandlerModule()
|
||||
const deferred = createDeferred<OAuthTokenData>()
|
||||
const refresh = mock(() => deferred.promise)
|
||||
const provider: OAuthProviderLike = {
|
||||
tokens: () => ({
|
||||
accessToken: "expired-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
||||
}),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}
|
||||
const authProviders = new Map<string, OAuthProviderLike>()
|
||||
const createOAuthProvider: OAuthProviderFactory = () => provider
|
||||
|
||||
// when
|
||||
const firstRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider)
|
||||
const secondRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider)
|
||||
|
||||
// then
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
deferred.resolve({ accessToken: "refreshed-token" })
|
||||
await expect(firstRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } })
|
||||
await expect(secondRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } })
|
||||
})
|
||||
|
||||
it("allows different servers to refresh independently after request auth errors", async () => {
|
||||
// given
|
||||
const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule()
|
||||
const firstDeferred = createDeferred<OAuthTokenData>()
|
||||
const secondDeferred = createDeferred<OAuthTokenData>()
|
||||
const firstProvider: OAuthProviderLike = {
|
||||
tokens: () => ({ accessToken: "expired-a", refreshToken: "refresh-a" }),
|
||||
login: mock(async () => ({ accessToken: "login-a" } satisfies OAuthTokenData)),
|
||||
refresh: mock(() => firstDeferred.promise),
|
||||
}
|
||||
const secondProvider: OAuthProviderLike = {
|
||||
tokens: () => ({ accessToken: "expired-b", refreshToken: "refresh-b" }),
|
||||
login: mock(async () => ({ accessToken: "login-b" } satisfies OAuthTokenData)),
|
||||
refresh: mock(() => secondDeferred.promise),
|
||||
}
|
||||
const providers = new Map([
|
||||
["https://server-a.example.com/mcp", firstProvider],
|
||||
["https://server-b.example.com/mcp", secondProvider],
|
||||
])
|
||||
|
||||
// when
|
||||
const firstAttempt = handlePostRequestAuthError({
|
||||
error: new Error("401 Unauthorized"),
|
||||
config: createConfig("https://server-a.example.com/mcp"),
|
||||
authProviders: providers,
|
||||
})
|
||||
const secondAttempt = handlePostRequestAuthError({
|
||||
error: new Error("403 Forbidden"),
|
||||
config: createConfig("https://server-b.example.com/mcp"),
|
||||
authProviders: providers,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(firstProvider.refresh).toHaveBeenCalledTimes(1)
|
||||
expect(secondProvider.refresh).toHaveBeenCalledTimes(1)
|
||||
firstDeferred.resolve({ accessToken: "refreshed-a" })
|
||||
secondDeferred.resolve({ accessToken: "refreshed-b" })
|
||||
await expect(firstAttempt).resolves.toBe(true)
|
||||
await expect(secondAttempt).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it("allows a new refresh after the previous same-server refresh completes", async () => {
|
||||
// given
|
||||
const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule()
|
||||
const refresh = mock(async () => ({ accessToken: `refreshed-${refresh.mock.calls.length + 1}` } satisfies OAuthTokenData))
|
||||
const provider: OAuthProviderLike = {
|
||||
tokens: () => ({ accessToken: "expired-token", refreshToken: "refresh-token" }),
|
||||
login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)),
|
||||
refresh,
|
||||
}
|
||||
const authProviders = new Map<string, OAuthProviderLike>([["https://same.example.com/mcp", provider]])
|
||||
|
||||
// when
|
||||
const firstResult = await handlePostRequestAuthError({
|
||||
error: new Error("401 Unauthorized"),
|
||||
config: createConfig("https://same.example.com/mcp"),
|
||||
authProviders,
|
||||
})
|
||||
const secondResult = await handlePostRequestAuthError({
|
||||
error: new Error("401 Unauthorized"),
|
||||
config: createConfig("https://same.example.com/mcp"),
|
||||
authProviders,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(firstResult).toBe(true)
|
||||
expect(secondResult).toBe(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||
import { withRefreshMutex } from "../mcp-oauth/refresh-mutex"
|
||||
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
||||
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
||||
@@ -52,14 +53,15 @@ export async function buildHttpRequestInit(
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData && isTokenExpired(tokenData)) {
|
||||
try {
|
||||
tokenData = tokenData.refreshToken
|
||||
? await provider.refresh(tokenData.refreshToken)
|
||||
: await provider.login()
|
||||
} catch {
|
||||
if (tokenData && isTokenExpired(tokenData)) {
|
||||
try {
|
||||
tokenData = await provider.login()
|
||||
const refreshToken = tokenData.refreshToken
|
||||
tokenData = refreshToken
|
||||
? await withRefreshMutex(config.url, () => provider.refresh(refreshToken))
|
||||
: await provider.login()
|
||||
} catch {
|
||||
try {
|
||||
tokenData = await provider.login()
|
||||
} catch {
|
||||
tokenData = null
|
||||
}
|
||||
@@ -116,3 +118,43 @@ export async function handleStepUpIfNeeded(params: {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePostRequestAuthError(params: {
|
||||
error: Error
|
||||
config: ClaudeCodeMcpServer
|
||||
authProviders: Map<string, OAuthProviderLike>
|
||||
createOAuthProvider?: OAuthProviderFactory
|
||||
refreshAttempted?: Set<string>
|
||||
}): Promise<boolean> {
|
||||
const { error, config, authProviders, createOAuthProvider, refreshAttempted = new Set() } = params
|
||||
|
||||
if (!config.oauth || !config.url) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statusMatch = /\b(401|403)\b/.exec(error.message)
|
||||
if (!statusMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider)
|
||||
const tokenData = provider.tokens()
|
||||
|
||||
if (!tokenData?.refreshToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (refreshAttempted.has(config.url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
refreshAttempted.add(config.url)
|
||||
|
||||
try {
|
||||
const refreshToken = tokenData.refreshToken
|
||||
await withRefreshMutex(config.url, () => provider.refresh(refreshToken))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
||||
import { redactSensitiveData } from "./error-redaction"
|
||||
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
|
||||
|
||||
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
|
||||
@@ -45,10 +46,13 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams):
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const fullCommand = `${command} ${args.join(" ")}`
|
||||
const safeCommand = redactSensitiveData(fullCommand)
|
||||
const safeErrorMessage = redactSensitiveData(errorMessage)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
`Command: ${command} ${args.join(" ")}\n` +
|
||||
`Reason: ${errorMessage}\n\n` +
|
||||
`Command: ${safeCommand}\n` +
|
||||
`Reason: ${safeErrorMessage}\n\n` +
|
||||
`Hints:\n` +
|
||||
` - Ensure the command is installed and available in PATH\n` +
|
||||
` - Check if the MCP server package exists\n` +
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdi
|
||||
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import type { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||
import type { SkillScope } from "../opencode-skill-loader/types"
|
||||
|
||||
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||
|
||||
@@ -10,6 +11,7 @@ export interface SkillMcpClientInfo {
|
||||
serverName: string
|
||||
skillName: string
|
||||
sessionID: string
|
||||
scope?: SkillScope | "local"
|
||||
}
|
||||
|
||||
export interface SkillMcpServerContext {
|
||||
|
||||
@@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({
|
||||
mock.module("../../shared/tmux", () => ({
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
isServerRunning: mock(async () => true),
|
||||
resetServerCheck: mock(() => {}),
|
||||
markServerRunningInProcess: mock(() => {}),
|
||||
getPaneDimensions: mock(async () => ({ width: 220, height: 44 })),
|
||||
spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })),
|
||||
closeTmuxPane: mock(async () => ({ success: true })),
|
||||
replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })),
|
||||
spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })),
|
||||
spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })),
|
||||
applyLayout: mock(async () => ({ success: true })),
|
||||
enforceMainPaneWidth: mock(async () => ({ success: true })),
|
||||
POLL_INTERVAL_BACKGROUND_MS: 10,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 10,
|
||||
SESSION_READY_TIMEOUT_MS: 50,
|
||||
SESSION_MISSING_GRACE_MS: 1_000,
|
||||
SESSION_TIMEOUT_MS: 600_000,
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
@@ -121,4 +121,64 @@ describe("injectBoulderContinuation", () => {
|
||||
expect(result).toBe("skipped_agent_unavailable")
|
||||
expect(promptAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("#given recent prompt context includes variant #when injecting boulder continuation #then promptAsync receives variant as a top-level field", async () => {
|
||||
// given
|
||||
registerAgentName("atlas")
|
||||
const capturedRequests: Array<{
|
||||
body?: {
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const promptAsyncMock = mock(async (request: unknown) => {
|
||||
capturedRequests.push(request as typeof capturedRequests[number])
|
||||
return undefined
|
||||
})
|
||||
const recentModel = {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
variant: "max",
|
||||
}
|
||||
const messagesMock = mock(async () => ({
|
||||
data: [{
|
||||
id: "msg_1",
|
||||
info: {
|
||||
agent: "atlas",
|
||||
model: recentModel,
|
||||
time: { created: Date.now() },
|
||||
},
|
||||
}],
|
||||
}))
|
||||
|
||||
const ctx = {
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
messages: messagesMock,
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
ctx,
|
||||
sessionID: "ses_test_variant",
|
||||
planName: "test-plan",
|
||||
remaining: 1,
|
||||
total: 2,
|
||||
agent: "atlas",
|
||||
sessionState: { promptFailureCount: 0 },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBe("injected")
|
||||
expect(capturedRequests).toHaveLength(1)
|
||||
expect(capturedRequests[0]?.body?.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
})
|
||||
expect(capturedRequests[0]?.body?.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { isAgentRegistered } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
@@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: {
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||
preferredSessionContext +
|
||||
worktreeContext
|
||||
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
|
||||
const continuationAgent = resolveRegisteredAgentName(
|
||||
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||
)
|
||||
|
||||
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
||||
@@ -71,12 +76,18 @@ export async function injectBoulderContinuation(input: {
|
||||
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: continuationAgent,
|
||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
const launchModel = promptContext.model
|
||||
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = promptContext.model?.variant
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: continuationAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
|
||||
@@ -40,7 +40,14 @@ export async function resolveRecentPromptContextForSession(
|
||||
const model = info?.model
|
||||
const tools = normalizePromptTools(info?.tools)
|
||||
if (model?.providerID && model?.modelID) {
|
||||
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
|
||||
return {
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
...(model.variant ? { variant: model.variant } : {}),
|
||||
},
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
if (info?.providerID && info?.modelID) {
|
||||
@@ -63,7 +70,14 @@ export async function resolveRecentPromptContextForSession(
|
||||
if (!model?.providerID || !model?.modelID) {
|
||||
return { tools }
|
||||
}
|
||||
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
|
||||
return {
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
...(model.variant ? { variant: model.variant } : {}),
|
||||
},
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRecentModelForSession(
|
||||
|
||||
@@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { PART_STORAGE } from "../../shared"
|
||||
|
||||
const testDirs: string[] = []
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
||||
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
|
||||
// then
|
||||
expect(result).toBe("atlas")
|
||||
})
|
||||
|
||||
test("skips JSON messages whose part storage contains a compaction marker", async () => {
|
||||
// given
|
||||
const sessionID = "ses_json_compaction_marker"
|
||||
const messageDir = createTempMessageDir(sessionID)
|
||||
const compactionMessageID = "msg_test_atlas_compaction_marker"
|
||||
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||
testDirs.push(partDir)
|
||||
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
|
||||
id: compactionMessageID,
|
||||
agent: "atlas",
|
||||
time: { created: 200 },
|
||||
}), "utf-8")
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({
|
||||
type: "compaction",
|
||||
}), "utf-8")
|
||||
|
||||
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
|
||||
id: "msg_0002",
|
||||
agent: "sisyphus-junior",
|
||||
time: { created: 100 },
|
||||
}), "utf-8")
|
||||
|
||||
const { getLastAgentFromSession } = await import("./session-last-agent")
|
||||
|
||||
// when
|
||||
const result = await getLastAgentFromSession(sessionID)
|
||||
|
||||
// then
|
||||
expect(result).toBe("sisyphus-junior")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,30 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
|
||||
expect(result).toBe("sisyphus-junior")
|
||||
})
|
||||
|
||||
test("skips compaction marker user messages that retain the original agent", async () => {
|
||||
// given
|
||||
const client = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 100 } } },
|
||||
{
|
||||
id: "msg_compaction",
|
||||
info: { agent: "atlas", time: { created: 200 } },
|
||||
parts: [{ type: "compaction" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never)
|
||||
|
||||
// then
|
||||
expect(result).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("returns null instead of throwing when SQLite message lookup fails", async () => {
|
||||
// given
|
||||
const client = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
||||
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||
|
||||
type SessionMessagesClient = {
|
||||
session: {
|
||||
@@ -9,10 +10,6 @@ type SessionMessagesClient = {
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactionAgent(agent: unknown): boolean {
|
||||
return typeof agent === "string" && agent.toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||
try {
|
||||
const messages = readdirSync(messageDir)
|
||||
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||
.map((fileName) => {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
||||
const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } }
|
||||
const parsed = JSON.parse(content) as { id?: string; agent?: unknown; time?: { created?: unknown } }
|
||||
return {
|
||||
fileName,
|
||||
id: parsed.id,
|
||||
agent: parsed.agent,
|
||||
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
|
||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
||||
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
|
||||
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) {
|
||||
if (!message) continue
|
||||
if (isCompactionMessage({ agent: message.agent }) || hasCompactionPartInStorage(message?.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof message.agent === "string") {
|
||||
return message.agent.toLowerCase()
|
||||
}
|
||||
}
|
||||
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
|
||||
if (isSqliteBackend() && client) {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, {
|
||||
const messages = normalizeSDKResponse(response, [] as Array<{
|
||||
id?: string
|
||||
info?: { agent?: string; time?: { created?: number } }
|
||||
parts?: Array<{ type?: string }>
|
||||
}>, {
|
||||
preferResponseOnMissingData: true,
|
||||
}).sort((left, right) => {
|
||||
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
|
||||
@@ -67,8 +74,12 @@ export async function getLastAgentFromSession(
|
||||
})
|
||||
|
||||
for (const message of messages) {
|
||||
if (isCompactionMessage(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const agent = message.info?.agent
|
||||
if (typeof agent === "string" && !isCompactionAgent(agent)) {
|
||||
if (typeof agent === "string") {
|
||||
return agent.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentOverrides } from "../../config"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { TopLevelTaskRef } from "../../features/boulder-state"
|
||||
|
||||
export type ModelInfo = { providerID: string; modelID: string }
|
||||
export type ModelInfo = { providerID: string; modelID: string; variant?: string }
|
||||
|
||||
export interface AtlasHookOptions {
|
||||
directory: string
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
// Hold mutable mock state so beforeEach can swap the cache root for each test.
|
||||
const mockState: { candidates: string[] } = { candidates: [] }
|
||||
|
||||
mock.module("../constants", () => ({
|
||||
INSTALLED_PACKAGE_JSON_CANDIDATES: new Proxy([], {
|
||||
get(_, prop) {
|
||||
const current = mockState.candidates
|
||||
// Forward array methods/properties to the mutable candidates list
|
||||
// so getCachedVersion's `for (... of ...)` sees fresh data per test.
|
||||
const value = (current as unknown as Record<PropertyKey, unknown>)[prop]
|
||||
if (typeof value === "function") {
|
||||
return (value as (...args: unknown[]) => unknown).bind(current)
|
||||
}
|
||||
return value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("./package-json-locator", () => ({
|
||||
findPackageJsonUp: () => null,
|
||||
}))
|
||||
|
||||
import { getCachedVersion } from "./cached-version"
|
||||
|
||||
describe("getCachedVersion (GH-3257)", () => {
|
||||
let cacheRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
cacheRoot = mkdtempSync(join(tmpdir(), "omo-cached-version-"))
|
||||
mockState.candidates = [
|
||||
join(cacheRoot, "node_modules", "oh-my-opencode", "package.json"),
|
||||
join(cacheRoot, "node_modules", "oh-my-openagent", "package.json"),
|
||||
]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cacheRoot, { recursive: true, force: true })
|
||||
mockState.candidates = []
|
||||
})
|
||||
|
||||
it("returns the version when the package is installed under oh-my-opencode", () => {
|
||||
const pkgDir = join(cacheRoot, "node_modules", "oh-my-opencode")
|
||||
mkdirSync(pkgDir, { recursive: true })
|
||||
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||
|
||||
expect(getCachedVersion()).toBe("3.16.0")
|
||||
})
|
||||
|
||||
it("returns the version when the package is installed under oh-my-openagent", () => {
|
||||
// GH-3257: npm users who install the aliased `oh-my-openagent` package get
|
||||
// node_modules/oh-my-openagent/package.json, not the canonical oh-my-opencode
|
||||
// path. The cached version resolver must check both.
|
||||
const pkgDir = join(cacheRoot, "node_modules", "oh-my-openagent")
|
||||
mkdirSync(pkgDir, { recursive: true })
|
||||
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||
|
||||
expect(getCachedVersion()).toBe("3.16.0")
|
||||
})
|
||||
|
||||
it("prefers oh-my-opencode when both are installed", () => {
|
||||
const legacyDir = join(cacheRoot, "node_modules", "oh-my-opencode")
|
||||
mkdirSync(legacyDir, { recursive: true })
|
||||
writeFileSync(join(legacyDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||
|
||||
const aliasDir = join(cacheRoot, "node_modules", "oh-my-openagent")
|
||||
mkdirSync(aliasDir, { recursive: true })
|
||||
writeFileSync(join(aliasDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.15.0" }))
|
||||
|
||||
expect(getCachedVersion()).toBe("3.16.0")
|
||||
})
|
||||
|
||||
it("returns null when neither candidate exists and fallbacks find nothing", () => {
|
||||
expect(getCachedVersion()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,18 +3,20 @@ import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { PackageJson } from "../types"
|
||||
import { INSTALLED_PACKAGE_JSON } from "../constants"
|
||||
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
const content = fs.readFileSync(candidate, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {
|
||||
// ignore; try next candidate
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
|
||||
@@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null {
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
if (!entry.startsWith("file://")) continue
|
||||
if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
describe("findPackageJsonUp", () => {
|
||||
let workdir: string
|
||||
|
||||
beforeEach(() => {
|
||||
workdir = mkdtempSync(join(tmpdir(), "omo-pkg-locator-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("finds a package.json whose name is the canonical oh-my-opencode", () => {
|
||||
const pkgPath = join(workdir, "package.json")
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
|
||||
|
||||
const found = findPackageJsonUp(workdir)
|
||||
|
||||
expect(found).toBe(pkgPath)
|
||||
})
|
||||
|
||||
it("finds a package.json whose name is the aliased oh-my-openagent (GH-3257)", () => {
|
||||
// A user who installed `oh-my-openagent` from npm gets a node_modules entry
|
||||
// whose package.json has `name: "oh-my-openagent"`. The auto-update-checker
|
||||
// must still resolve it so the startup toast shows a real version instead
|
||||
// of "unknown".
|
||||
const pkgPath = join(workdir, "package.json")
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||
|
||||
const found = findPackageJsonUp(workdir)
|
||||
|
||||
expect(found).toBe(pkgPath)
|
||||
})
|
||||
|
||||
it("walks up directories to find the matching package.json", () => {
|
||||
const nested = join(workdir, "dist", "checker")
|
||||
mkdirSync(nested, { recursive: true })
|
||||
const pkgPath = join(workdir, "package.json")
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
|
||||
|
||||
const found = findPackageJsonUp(nested)
|
||||
|
||||
expect(found).toBe(pkgPath)
|
||||
})
|
||||
|
||||
it("ignores unrelated package.json files", () => {
|
||||
const pkgPath = join(workdir, "package.json")
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: "some-other-package", version: "1.0.0" }))
|
||||
|
||||
const found = findPackageJsonUp(workdir)
|
||||
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no package.json exists", () => {
|
||||
const found = findPackageJsonUp(workdir)
|
||||
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { PackageJson } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
|
||||
|
||||
const ACCEPTED_NAME_SET = new Set<string>(ACCEPTED_PACKAGE_NAMES)
|
||||
|
||||
export function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
@@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.name === PACKAGE_NAME) return pkgPath
|
||||
if (pkg.name && ACCEPTED_NAME_SET.has(pkg.name)) return pkgPath
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { spawnSync } from "node:child_process"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-openagent"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||
|
||||
type PluginEntryResult = {
|
||||
entry: string
|
||||
@@ -121,6 +121,64 @@ describe("findPluginEntry", () => {
|
||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
||||
})
|
||||
|
||||
test("finds preferred plugin entry", async () => {
|
||||
// #given preferred plugin entry is configured
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: [PLUGIN_NAME] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const execution = runFindPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then preferred entry is returned
|
||||
expect(execution.status).toBe(0)
|
||||
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||
expect(pluginInfo?.entry).toBe(PLUGIN_NAME)
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||
})
|
||||
|
||||
test("finds legacy plugin entry", async () => {
|
||||
// #given legacy plugin entry is configured
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: [LEGACY_PLUGIN_NAME] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const execution = runFindPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then legacy entry is returned
|
||||
expect(execution.status).toBe(0)
|
||||
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||
expect(pluginInfo?.entry).toBe(LEGACY_PLUGIN_NAME)
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||
})
|
||||
|
||||
test("finds preferred plugin entry with pinned version", async () => {
|
||||
// #given preferred plugin entry includes semver version
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PLUGIN_NAME}@3.15.0`] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const execution = runFindPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then preferred versioned entry is returned
|
||||
expect(execution.status).toBe(0)
|
||||
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||
expect(pluginInfo?.entry).toBe(`${PLUGIN_NAME}@3.15.0`)
|
||||
expect(pluginInfo?.isPinned).toBe(true)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("3.15.0")
|
||||
})
|
||||
|
||||
test("returns null for unrelated plugin entry", async () => {
|
||||
// #given unrelated plugin entry is configured
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["some-other-plugin"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const execution = runFindPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then no matching entry is returned
|
||||
expect(execution.status).toBe(0)
|
||||
const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult
|
||||
expect(pluginInfo).toBeNull()
|
||||
})
|
||||
|
||||
test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => {
|
||||
// #given profile-specific user config after module import
|
||||
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string
|
||||
@@ -12,6 +13,7 @@ export interface PluginEntryInfo {
|
||||
}
|
||||
|
||||
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
||||
const MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME]
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
@@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||
return { entry, isPinned, pinnedVersion, configPath }
|
||||
for (const pluginName of MATCH_PLUGIN_NAMES) {
|
||||
if (entry === pluginName) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||
}
|
||||
if (entry.startsWith(`${pluginName}@`)) {
|
||||
const pinnedVersion = entry.slice(pluginName.length + 1)
|
||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||
return { entry, isPinned, pinnedVersion, configPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user