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"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun run script/run-ci-tests.ts
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun run script/run-ci-tests.ts
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# oh-my-opencode — O P E N C O D E Plugin
|
# 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
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$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",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"$schema": {
|
"$schema": {
|
||||||
@@ -6068,8 +6071,5 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"git_master"
|
"git_master"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@opencode-ai/plugin": "^1.2.24",
|
"@opencode-ai/plugin": "^1.4.0",
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
"@opencode-ai/sdk": "^1.4.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
@@ -20,8 +20,7 @@
|
|||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"vscode-jsonrpc": "^8.2.0",
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
"zod": "^3.24.0",
|
"zod": "^4.3.0",
|
||||||
"zod-to-json-schema": "^3.25.1",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
@@ -30,17 +29,17 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64": "3.15.3",
|
"oh-my-opencode-linux-x64": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64": "3.15.3",
|
"oh-my-opencode-windows-x64": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64-baseline": "3.15.3",
|
"oh-my-opencode-windows-x64-baseline": "3.16.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -49,9 +48,6 @@
|
|||||||
"@ast-grep/napi",
|
"@ast-grep/napi",
|
||||||
"@code-yeongyu/comment-checker",
|
"@code-yeongyu/comment-checker",
|
||||||
],
|
],
|
||||||
"overrides": {
|
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
|
||||||
},
|
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode",
|
"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",
|
"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",
|
"main": "./dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@opencode-ai/plugin": "^1.2.24",
|
"@opencode-ai/plugin": "^1.4.0",
|
||||||
"@opencode-ai/sdk": "^1.2.24",
|
"@opencode-ai/sdk": "^1.4.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
@@ -69,8 +69,7 @@
|
|||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"vscode-jsonrpc": "^8.2.0",
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
"zod-to-json-schema": "^3.25.1",
|
"zod": "^4.3.0"
|
||||||
"zod": "^3.24.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
@@ -79,21 +78,19 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.15.3",
|
"oh-my-opencode-darwin-arm64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.15.3",
|
"oh-my-opencode-darwin-x64": "3.16.0",
|
||||||
"oh-my-opencode-darwin-x64-baseline": "3.15.3",
|
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.15.3",
|
"oh-my-opencode-linux-arm64": "3.16.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.15.3",
|
"oh-my-opencode-linux-arm64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64": "3.15.3",
|
"oh-my-opencode-linux-x64": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-baseline": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.15.3",
|
"oh-my-opencode-linux-x64-musl": "3.16.0",
|
||||||
"oh-my-opencode-linux-x64-musl-baseline": "3.15.3",
|
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64": "3.15.3",
|
"oh-my-opencode-windows-x64": "3.16.0",
|
||||||
"oh-my-opencode-windows-x64-baseline": "3.15.3"
|
"oh-my-opencode-windows-x64-baseline": "3.16.0"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@opencode-ai/sdk": "^1.2.24"
|
|
||||||
},
|
},
|
||||||
|
"overrides": {},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@ast-grep/cli",
|
||||||
"@ast-grep/napi",
|
"@ast-grep/napi",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-arm64",
|
"name": "oh-my-opencode-darwin-arm64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64",
|
"name": "oh-my-opencode-darwin-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64-musl",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64",
|
"name": "oh-my-opencode-linux-arm64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-baseline",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64",
|
"name": "oh-my-opencode-linux-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64-baseline",
|
"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)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64",
|
"name": "oh-my-opencode-windows-x64",
|
||||||
"version": "3.15.3",
|
"version": "3.16.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+63
-1
@@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
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
|
* Detect libc family on Linux
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +90,15 @@ function main() {
|
|||||||
const { platform, arch } = process;
|
const { platform, arch } = process;
|
||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
const packageBaseName = getPackageBaseName();
|
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 {
|
try {
|
||||||
const packageCandidates = getPlatformPackageCandidates({
|
const packageCandidates = getPlatformPackageCandidates({
|
||||||
platform,
|
platform,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { z } from "zod"
|
||||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||||
|
|
||||||
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
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 {
|
return {
|
||||||
...jsonSchema,
|
|
||||||
$schema: "http://json-schema.org/draft-07/schema#",
|
$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",
|
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
title: "Oh My OpenCode Configuration",
|
title: "Oh My OpenCode Configuration",
|
||||||
description: "Configuration schema for oh-my-opencode plugin",
|
description: "Configuration schema for oh-my-opencode plugin",
|
||||||
|
...jsonSchema,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("test workflows", () => {
|
|||||||
const workflow = readFileSync(workflowPath, "utf8")
|
const workflow = readFileSync(workflowPath, "utf8")
|
||||||
|
|
||||||
expect(workflow).toContain("- name: Run tests")
|
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 {
|
function toIsolatedTarget(testFile: string): string {
|
||||||
const pathSegments = testFile.split("/")
|
return testFile
|
||||||
|
|
||||||
if (pathSegments.length <= 3) {
|
|
||||||
return testFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathSegments.slice(0, -1).join("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
|
function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"types": ["bun-types"],
|
"types": ["bun-types"],
|
||||||
|
"skipLibCheck": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2599,6 +2599,54 @@
|
|||||||
"created_at": "2026-04-06T15:37:54Z",
|
"created_at": "2026-04-06T15:37:54Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 3160
|
"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 type { AgentMode, AgentPromptMetadata } from "../types"
|
||||||
import { isGptModel, isGeminiModel } from "../types"
|
import { isGptModel, isGeminiModel } from "../types"
|
||||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
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 type { CategoryConfig } from "../../config/schema"
|
||||||
import { mergeCategories } from "../../shared/merge-categories"
|
import { mergeCategories } from "../../shared/merge-categories"
|
||||||
|
|
||||||
@@ -88,9 +88,13 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
|||||||
const skillsSection = buildSkillsSection(skills)
|
const skillsSection = buildSkillsSection(skills)
|
||||||
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, 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)
|
const basePrompt = getAtlasPrompt(model)
|
||||||
|
|
||||||
return basePrompt
|
return agentIdentity + "\n" + basePrompt
|
||||||
.replace("{CATEGORY_SECTION}", categorySection)
|
.replace("{CATEGORY_SECTION}", categorySection)
|
||||||
.replace("{AGENT_SECTION}", agentSection)
|
.replace("{AGENT_SECTION}", agentSection)
|
||||||
.replace("{DECISION_MATRIX}", decisionMatrix)
|
.replace("{DECISION_MATRIX}", decisionMatrix)
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import type {
|
|||||||
import type { AvailableTool } from "./dynamic-agent-prompt-types"
|
import type { AvailableTool } from "./dynamic-agent-prompt-types"
|
||||||
import { getToolsPromptDisplay } from "./dynamic-agent-tool-categorization"
|
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(
|
export function buildKeyTriggersSection(
|
||||||
agents: AvailableAgent[],
|
agents: AvailableAgent[],
|
||||||
_skills: AvailableSkill[] = [],
|
_skills: AvailableSkill[] = [],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type {
|
|||||||
export { categorizeTools } from "./dynamic-agent-tool-categorization"
|
export { categorizeTools } from "./dynamic-agent-tool-categorization"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
buildAgentIdentitySection,
|
||||||
buildKeyTriggersSection,
|
buildKeyTriggersSection,
|
||||||
buildToolSelectionTable,
|
buildToolSelectionTable,
|
||||||
buildExploreSection,
|
buildExploreSection,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
AvailableSkill,
|
AvailableSkill,
|
||||||
AvailableCategory,
|
AvailableCategory,
|
||||||
} from "../dynamic-agent-prompt-builder";
|
} 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 buildGptPrompt } from "./gpt";
|
||||||
import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex";
|
import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex";
|
||||||
@@ -87,7 +87,12 @@ function buildDynamicHephaestusPrompt(ctx?: HephaestusContext): string {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return basePrompt;
|
const agentIdentity = buildAgentIdentitySection(
|
||||||
|
"Hephaestus",
|
||||||
|
"Autonomous deep worker for software engineering from OhMyOpenCode",
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${agentIdentity}\n${basePrompt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHephaestusAgent(
|
export function createHephaestusAgent(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
AvailableCategory,
|
AvailableCategory,
|
||||||
} from "./dynamic-agent-prompt-builder";
|
} from "./dynamic-agent-prompt-builder";
|
||||||
import {
|
import {
|
||||||
|
buildAgentIdentitySection,
|
||||||
buildKeyTriggersSection,
|
buildKeyTriggersSection,
|
||||||
buildToolSelectionTable,
|
buildToolSelectionTable,
|
||||||
buildExploreSection,
|
buildExploreSection,
|
||||||
@@ -72,7 +73,13 @@ function buildDynamicSisyphusPrompt(
|
|||||||
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
||||||
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO 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.
|
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.
|
**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,
|
AvailableCategory,
|
||||||
} from "../dynamic-agent-prompt-builder";
|
} from "../dynamic-agent-prompt-builder";
|
||||||
import {
|
import {
|
||||||
|
buildAgentIdentitySection,
|
||||||
buildKeyTriggersSection,
|
buildKeyTriggersSection,
|
||||||
buildToolSelectionTable,
|
buildToolSelectionTable,
|
||||||
buildExploreSection,
|
buildExploreSection,
|
||||||
@@ -106,6 +107,11 @@ export function buildGpt54SisyphusPrompt(
|
|||||||
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
|
||||||
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO 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>
|
const identityBlock = `<identity>
|
||||||
You are Sisyphus - an AI orchestrator from OhMyOpenCode.
|
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>
|
</verbosity_controls>
|
||||||
</style>`;
|
</style>`;
|
||||||
|
|
||||||
return `${identityBlock}
|
return `${agentIdentity}
|
||||||
|
${identityBlock}
|
||||||
|
|
||||||
${constraintsBlock}
|
${constraintsBlock}
|
||||||
|
|
||||||
|
|||||||
@@ -915,6 +915,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
"model": "opencode/claude-sonnet-4-6",
|
"model": "opencode/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "opencode/minimax-m2.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "opencode/gpt-5-nano",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "opencode/claude-haiku-4-5",
|
"model": "opencode/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -1132,6 +1140,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
"model": "opencode/claude-sonnet-4-6",
|
"model": "opencode/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "opencode/minimax-m2.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "opencode/gpt-5-nano",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "opencode/claude-haiku-4-5",
|
"model": "opencode/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -1353,6 +1369,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"model": "github-copilot/claude-sonnet-4.6",
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "github-copilot/grok-code-fast-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -1534,6 +1555,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"model": "github-copilot/claude-sonnet-4.6",
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "github-copilot/grok-code-fast-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -1842,6 +1868,17 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
"model": "anthropic/claude-sonnet-4-6",
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"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",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -2114,6 +2151,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
"model": "github-copilot/claude-sonnet-4.6",
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "github-copilot/grok-code-fast-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -2353,6 +2395,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
|||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"librarian": {
|
||||||
|
"fallback_models": [
|
||||||
|
{
|
||||||
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
|
},
|
||||||
|
],
|
||||||
"model": "zai-coding-plan/glm-4.7",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
@@ -2573,6 +2620,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"model": "github-copilot/claude-sonnet-4.6",
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"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",
|
"model": "opencode/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -2586,6 +2644,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"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",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
@@ -2949,6 +3018,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
"model": "anthropic/claude-sonnet-4-6",
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"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",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -2966,6 +3049,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"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",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
@@ -3472,6 +3569,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
"model": "anthropic/claude-sonnet-4-6",
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
"explore": {
|
"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",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
@@ -3489,6 +3600,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"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",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ describe("runCliInstaller", () => {
|
|||||||
console.error = originalConsoleError
|
console.error = originalConsoleError
|
||||||
})
|
})
|
||||||
|
|
||||||
it("completes installation without auth plugin or provider config steps", async () => {
|
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||||
//#given
|
// given
|
||||||
const restoreSpies = [
|
const restoreSpies = [
|
||||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
hasClaude: false,
|
hasClaude: false,
|
||||||
isMax20: false,
|
isMax20: false,
|
||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
@@ -34,9 +35,56 @@ describe("runCliInstaller", () => {
|
|||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
}),
|
}),
|
||||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
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({
|
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
configPath: "/tmp/opencode.jsonc",
|
configPath: "/tmp/opencode.jsonc",
|
||||||
@@ -56,12 +104,13 @@ describe("runCliInstaller", () => {
|
|||||||
opencodeZen: "no",
|
opencodeZen: "no",
|
||||||
zaiCodingPlan: "no",
|
zaiCodingPlan: "no",
|
||||||
kimiForCoding: "no",
|
kimiForCoding: "no",
|
||||||
|
opencodeGo: "no",
|
||||||
}
|
}
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
const result = await runCliInstaller(args, "3.4.0")
|
const result = await runCliInstaller(args, "3.4.0")
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(result).toBe(0)
|
expect(result).toBe(0)
|
||||||
|
|
||||||
for (const spy of restoreSpies) {
|
for (const spy of restoreSpies) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
printWarning,
|
printWarning,
|
||||||
validateNonTuiArgs,
|
validateNonTuiArgs,
|
||||||
} from "./install-validators"
|
} from "./install-validators"
|
||||||
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
|
|
||||||
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
const validation = validateNonTuiArgs(args)
|
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")
|
printInfo("Visit https://opencode.ai/docs for installation instructions")
|
||||||
} else {
|
} else {
|
||||||
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
|
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
|
||||||
|
|
||||||
|
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||||
|
if (unsupportedVersionMessage) {
|
||||||
|
printWarning(unsupportedVersionMessage)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config"
|
|||||||
|
|
||||||
export type { BunInstallResult } from "./config-manager/bun-install"
|
export type { BunInstallResult } from "./config-manager/bun-install"
|
||||||
export { runBunInstall, runBunInstallWithDetails } 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 { readFileSync, writeFileSync } from "node:fs"
|
||||||
import type { ConfigMergeResult } from "../types"
|
import type { ConfigMergeResult } from "../types"
|
||||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||||
|
import { backupConfigFile } from "./backup-config"
|
||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||||
import { detectConfigFormat } from "./opencode-config-format"
|
import { detectConfigFormat } from "./opencode-config-format"
|
||||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||||
|
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
|
||||||
|
|
||||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||||
try {
|
try {
|
||||||
@@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
|||||||
&& !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`))
|
&& !(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]
|
const normalizedPlugins = [...otherPlugins]
|
||||||
|
|
||||||
if (canonicalEntries.length > 0) {
|
if (canonicalEntries.length > 0 || legacyEntries.length > 0) {
|
||||||
normalizedPlugins.push(canonicalEntries[0])
|
normalizedPlugins.push(pluginEntry)
|
||||||
} else if (legacyEntries.length > 0) {
|
|
||||||
const versionMatch = legacyEntries[0].match(/@(.+)$/)
|
|
||||||
const preservedVersion = versionMatch ? versionMatch[1] : null
|
|
||||||
normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry)
|
|
||||||
} else {
|
} else {
|
||||||
normalizedPlugins.push(pluginEntry)
|
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 { getOmoConfigPath } from "./config-context"
|
||||||
import { detectConfigFormat } from "./opencode-config-format"
|
import { detectConfigFormat } from "./opencode-config-format"
|
||||||
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
||||||
|
import { extractVersionFromPluginEntry } from "./version-compatibility"
|
||||||
|
|
||||||
function detectProvidersFromOmoConfig(): {
|
function detectProvidersFromOmoConfig(): {
|
||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
@@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean {
|
|||||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findOurPluginEntry(plugins: string[]): string | null {
|
||||||
|
return plugins.find(isOurPlugin) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function detectCurrentConfig(): DetectedConfig {
|
export function detectCurrentConfig(): DetectedConfig {
|
||||||
const result: DetectedConfig = {
|
const result: DetectedConfig = {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
installedVersion: null,
|
||||||
hasClaude: true,
|
hasClaude: true,
|
||||||
isMax20: true,
|
isMax20: true,
|
||||||
hasOpenAI: true,
|
hasOpenAI: true,
|
||||||
@@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig {
|
|||||||
|
|
||||||
const openCodeConfig = parseResult.config
|
const openCodeConfig = parseResult.config
|
||||||
const plugins = openCodeConfig.plugin ?? []
|
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) {
|
if (!result.isInstalled) {
|
||||||
return result
|
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 { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -6,6 +6,7 @@ import { join } from "node:path"
|
|||||||
import { resetConfigContext } from "./config-context"
|
import { resetConfigContext } from "./config-context"
|
||||||
import { detectCurrentConfig } from "./detect-current-config"
|
import { detectCurrentConfig } from "./detect-current-config"
|
||||||
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
||||||
|
import * as pluginNameWithVersion from "./plugin-name-with-version"
|
||||||
|
|
||||||
describe("detectCurrentConfig - single package detection", () => {
|
describe("detectCurrentConfig - single package detection", () => {
|
||||||
let testConfigDir = ""
|
let testConfigDir = ""
|
||||||
@@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
|||||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
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
|
// 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
|
// when
|
||||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
const result = await addPluginToOpenCodeConfig("3.16.0")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
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 () => {
|
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"])
|
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
|
// 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")
|
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
const result = await addPluginToOpenCodeConfig("3.10.0")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
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.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 () => {
|
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,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
hasOpencodeGo: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecord(value: unknown): Record<string, unknown> {
|
function getRecord(value: unknown): Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||||
import { parseJsonc } from "../../shared"
|
import { parseJsonc } from "../../shared"
|
||||||
import type { ConfigMergeResult, InstallConfig } from "../types"
|
import type { ConfigMergeResult, InstallConfig } from "../types"
|
||||||
|
import { backupConfigFile } from "./backup-config"
|
||||||
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
||||||
import { deepMergeRecord } from "./deep-merge-record"
|
import { deepMergeRecord } from "./deep-merge-record"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
@@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
|||||||
const newConfig = generateOmoConfig(installConfig)
|
const newConfig = generateOmoConfig(installConfig)
|
||||||
|
|
||||||
if (existsSync(omoConfigPath)) {
|
if (existsSync(omoConfigPath)) {
|
||||||
|
const backupResult = backupConfigFile(omoConfigPath)
|
||||||
|
if (!backupResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configPath: omoConfigPath,
|
||||||
|
error: `Failed to create backup: ${backupResult.error}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = statSync(omoConfigPath)
|
const stat = statSync(omoConfigPath)
|
||||||
const content = readFileSync(omoConfigPath, "utf-8")
|
const content = readFileSync(omoConfigPath, "utf-8")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const EXIT_CODES = {
|
|||||||
FAILURE: 1,
|
FAILURE: 1,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const MIN_OPENCODE_VERSION = "1.0.150"
|
export const MIN_OPENCODE_VERSION = "1.4.0"
|
||||||
|
|
||||||
export const PACKAGE_NAME = PLUGIN_NAME
|
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 () => {
|
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
|
||||||
// given OpenCode binary IS installed
|
// given OpenCode binary IS installed
|
||||||
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
|
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
|
// given mock npm fetch
|
||||||
globalThis.fetch = mock(() =>
|
globalThis.fetch = mock(() =>
|
||||||
@@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => {
|
|||||||
// then should have printed success (OK symbol)
|
// then should have printed success (OK symbol)
|
||||||
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
|
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
|
||||||
expect(allCalls).toContain("[OK]")
|
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", () => {
|
describe("schema URL", () => {
|
||||||
test("always includes correct schema URL", () => {
|
test("always includes correct schema URL", () => {
|
||||||
// #given any config
|
// #given any config
|
||||||
|
|||||||
+41
-11
@@ -37,22 +37,29 @@ function toFallbackModelObject(entry: FallbackEntry, provider: string): Fallback
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
|
function collectAvailableFallbacks(
|
||||||
config: T,
|
|
||||||
fallbackChain: FallbackEntry[],
|
fallbackChain: FallbackEntry[],
|
||||||
availability: ReturnType<typeof toProviderAvailability>,
|
availability: ReturnType<typeof toProviderAvailability>,
|
||||||
): T {
|
): FallbackModelObject[] {
|
||||||
const expandedFallbacks = fallbackChain.flatMap((entry) =>
|
const expandedFallbacks = fallbackChain.flatMap((entry) =>
|
||||||
entry.providers
|
entry.providers
|
||||||
.filter((provider) => isProviderAvailable(provider, availability))
|
.filter((provider) => isProviderAvailable(provider, availability))
|
||||||
.map((provider) => toFallbackModelObject(entry, provider))
|
.map((provider) => toFallbackModelObject(entry, provider))
|
||||||
)
|
)
|
||||||
const uniqueFallbacks = expandedFallbacks.filter((entry, index, allEntries) =>
|
return expandedFallbacks.filter((entry, index, allEntries) =>
|
||||||
allEntries.findIndex((candidate) =>
|
allEntries.findIndex((candidate) =>
|
||||||
candidate.model === entry.model &&
|
candidate.model === entry.model &&
|
||||||
candidate.variant === entry.variant
|
candidate.variant === entry.variant
|
||||||
) === index
|
) === 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)
|
const primaryIndex = uniqueFallbacks.findIndex((entry) => entry.model === config.model)
|
||||||
if (primaryIndex === -1) {
|
if (primaryIndex === -1) {
|
||||||
return config
|
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 {
|
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)) {
|
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
|
||||||
if (role === "librarian") {
|
if (role === "librarian") {
|
||||||
|
let agentConfig: AgentConfig | undefined
|
||||||
if (avail.opencodeGo) {
|
if (avail.opencodeGo) {
|
||||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||||
} else if (avail.zai) {
|
} else if (avail.zai) {
|
||||||
agents[role] = { model: ZAI_MODEL }
|
agentConfig = { model: ZAI_MODEL }
|
||||||
|
}
|
||||||
|
if (agentConfig) {
|
||||||
|
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "explore") {
|
if (role === "explore") {
|
||||||
|
let agentConfig: AgentConfig
|
||||||
if (avail.native.claude) {
|
if (avail.native.claude) {
|
||||||
agents[role] = { model: "anthropic/claude-haiku-4-5" }
|
agentConfig = { model: "anthropic/claude-haiku-4-5" }
|
||||||
} else if (avail.opencodeZen) {
|
} else if (avail.opencodeZen) {
|
||||||
agents[role] = { model: "opencode/claude-haiku-4-5" }
|
agentConfig = { model: "opencode/claude-haiku-4-5" }
|
||||||
} else if (avail.opencodeGo) {
|
} else if (avail.opencodeGo) {
|
||||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||||
} else if (avail.copilot) {
|
} else if (avail.copilot) {
|
||||||
agents[role] = { model: "github-copilot/gpt-5-mini" }
|
agentConfig = { model: "github-copilot/gpt-5-mini" }
|
||||||
} else {
|
} else {
|
||||||
agents[role] = { model: "opencode/gpt-5-nano" }
|
agentConfig = { model: "opencode/gpt-5-nano" }
|
||||||
}
|
}
|
||||||
|
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
|
||||||
continue
|
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,
|
writeOmoConfig,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
||||||
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
import { promptInstallConfig } from "./tui-install-prompts"
|
import { promptInstallConfig } from "./tui-install-prompts"
|
||||||
|
|
||||||
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
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")
|
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
|
||||||
} else {
|
} else {
|
||||||
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
|
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)
|
const config = await promptInstallConfig(detected)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ConfigMergeResult {
|
|||||||
|
|
||||||
export interface DetectedConfig {
|
export interface DetectedConfig {
|
||||||
isInstalled: boolean
|
isInstalled: boolean
|
||||||
|
installedVersion: string | null
|
||||||
hasClaude: boolean
|
hasClaude: boolean
|
||||||
isMax20: boolean
|
isMax20: boolean
|
||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
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 { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
clearCompactionAgentConfigCheckpoint,
|
clearCompactionAgentConfigCheckpoint,
|
||||||
setCompactionAgentConfigCheckpoint,
|
setCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
describe("isCompactionAgent", () => {
|
describe("isCompactionAgent", () => {
|
||||||
describe("#given agent name variations", () => {
|
describe("#given agent name variations", () => {
|
||||||
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tempDir, { force: true, recursive: true })
|
rmSync(tempDir, { force: true, recursive: true })
|
||||||
|
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
|
||||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
expect(result?.agent).toBe("sisyphus")
|
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", () => {
|
test("falls back to partial agent/model match", () => {
|
||||||
// given
|
// given
|
||||||
const messageWithAgentOnly = {
|
const messageWithAgentOnly = {
|
||||||
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
|
|||||||
tools: { bash: true },
|
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 { join } from "node:path"
|
||||||
import type { StoredMessage } from "../hook-message-injector"
|
import type { StoredMessage } from "../hook-message-injector"
|
||||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import {
|
||||||
|
hasCompactionPartInStorage,
|
||||||
|
isCompactionAgent,
|
||||||
|
isCompactionMessage,
|
||||||
|
} from "../../shared/compaction-marker"
|
||||||
|
|
||||||
|
export { isCompactionAgent } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessage = {
|
type SessionMessage = {
|
||||||
|
id?: string
|
||||||
info?: {
|
info?: {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: {
|
model?: {
|
||||||
@@ -15,10 +23,7 @@ type SessionMessage = {
|
|||||||
modelID?: string
|
modelID?: string
|
||||||
tools?: StoredMessage["tools"]
|
tools?: StoredMessage["tools"]
|
||||||
}
|
}
|
||||||
}
|
parts?: Array<{ type?: string }>
|
||||||
|
|
||||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
|
||||||
return agent?.trim().toLowerCase() === "compaction"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||||
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = message.info
|
const info = message.info
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return null
|
return null
|
||||||
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
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 {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map<string, number
|
|||||||
return (manager as unknown as { rootDescendantCounts: Map<string, number> }).rootDescendantCounts
|
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(
|
function getQueuesByKey(
|
||||||
manager: BackgroundManager
|
manager: BackgroundManager
|
||||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||||
@@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
|||||||
prompt: promptMock,
|
prompt: promptMock,
|
||||||
promptAsync: promptMock,
|
promptAsync: promptMock,
|
||||||
abort: async () => ({}),
|
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(
|
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", () => {
|
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||||
test("should prepend queued notifications to first text part and clear queue", () => {
|
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||||
// given
|
// given
|
||||||
@@ -1431,6 +1541,47 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
|||||||
expect(task.concurrencyKey).toBeUndefined()
|
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 () => {
|
test("should release queue slot when queued task is already interrupt", async () => {
|
||||||
// given
|
// given
|
||||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||||
@@ -1739,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => {
|
|||||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||||
temperature: 0.25,
|
temperature: 0.25,
|
||||||
topP: 0.55,
|
topP: 0.55,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
options: {
|
options: {
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
maxTokens: 8192,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2339,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
expect(retryTask.status).toBe("pending")
|
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 () => {
|
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
||||||
// given
|
// given
|
||||||
const firstSessionID = "ses-first-cancelled-during-create"
|
const firstSessionID = "ses-first-cancelled-during-create"
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ export class BackgroundManager {
|
|||||||
this.markPreStartDescendantReservation(task)
|
this.markPreStartDescendantReservation(task)
|
||||||
|
|
||||||
// Trigger processing (fire-and-forget)
|
// Trigger processing (fire-and-forget)
|
||||||
this.processKey(key)
|
void this.processKey(key)
|
||||||
|
|
||||||
return { ...task }
|
return { ...task }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -408,12 +408,31 @@ export class BackgroundManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[background-agent] Error starting task:", error)
|
log("[background-agent] Error starting task:", error)
|
||||||
this.rollbackPreStartDescendantReservation(item.task)
|
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) {
|
if (item.task.concurrencyKey) {
|
||||||
this.concurrencyManager.release(item.task.concurrencyKey)
|
this.concurrencyManager.release(item.task.concurrencyKey)
|
||||||
item.task.concurrencyKey = undefined
|
item.task.concurrencyKey = undefined
|
||||||
} else {
|
} else {
|
||||||
this.concurrencyManager.release(key)
|
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 {
|
} finally {
|
||||||
@@ -1760,6 +1779,7 @@ export class BackgroundManager {
|
|||||||
let agent: string | undefined = task.parentAgent
|
let agent: string | undefined = task.parentAgent
|
||||||
let model: { providerID: string; modelID: string } | undefined
|
let model: { providerID: string; modelID: string } | undefined
|
||||||
let tools: Record<string, boolean> | undefined = task.parentTools
|
let tools: Record<string, boolean> | undefined = task.parentTools
|
||||||
|
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
|
||||||
|
|
||||||
if (this.enableParentSessionNotifications) {
|
if (this.enableParentSessionNotifications) {
|
||||||
try {
|
try {
|
||||||
@@ -1773,7 +1793,7 @@ export class BackgroundManager {
|
|||||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||||
}
|
}
|
||||||
}>)
|
}>)
|
||||||
const promptContext = resolvePromptContextFromSessionMessages(
|
promptContext = resolvePromptContextFromSessionMessages(
|
||||||
messages,
|
messages,
|
||||||
task.parentSessionID,
|
task.parentSessionID,
|
||||||
)
|
)
|
||||||
@@ -1817,6 +1837,8 @@ export class BackgroundManager {
|
|||||||
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
||||||
const shouldReply = allComplete || isTaskFailure
|
const shouldReply = allComplete || isTaskFailure
|
||||||
|
|
||||||
|
const variant = promptContext?.model?.variant
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.client.session.promptAsync({
|
await this.client.session.promptAsync({
|
||||||
path: { id: task.parentSessionID },
|
path: { id: task.parentSessionID },
|
||||||
@@ -1824,6 +1846,7 @@ export class BackgroundManager {
|
|||||||
noReply: !shouldReply,
|
noReply: !shouldReply,
|
||||||
...(agent !== undefined ? { agent } : {}),
|
...(agent !== undefined ? { agent } : {}),
|
||||||
...(model !== undefined ? { model } : {}),
|
...(model !== undefined ? { model } : {}),
|
||||||
|
...(variant !== undefined ? { variant } : {}),
|
||||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||||
parts: [createInternalAgentTextPart(notification)],
|
parts: [createInternalAgentTextPart(notification)],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => {
|
|||||||
expect(getSessionPromptParams("session-123")).toEqual({
|
expect(getSessionPromptParams("session-123")).toEqual({
|
||||||
temperature: 0.4,
|
temperature: 0.4,
|
||||||
topP: 0.7,
|
topP: 0.7,
|
||||||
|
maxOutputTokens: 4096,
|
||||||
options: {
|
options: {
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
maxTokens: 4096,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => {
|
|||||||
})
|
})
|
||||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
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 { subagentSessions } from "../claude-code-session-state"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import type { ConcurrencyManager } from "./concurrency"
|
import type { ConcurrencyManager } from "./concurrency"
|
||||||
|
|
||||||
export const FALLBACK_AGENT = "general"
|
export const FALLBACK_AGENT = "general"
|
||||||
@@ -168,11 +169,12 @@ export async function startTask(
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
const launchVariant = input.model?.variant
|
||||||
|
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||||
|
|
||||||
applySessionPromptParams(sessionID, input.model)
|
applySessionPromptParams(sessionID, input.model)
|
||||||
|
|
||||||
const promptBody = {
|
const promptBody = {
|
||||||
agent: input.agent,
|
agent: normalizedAgent,
|
||||||
...(launchModel ? { model: launchModel } : {}),
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
...(launchVariant ? { variant: launchVariant } : {}),
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
system: input.skillContent,
|
system: input.skillContent,
|
||||||
@@ -180,7 +182,7 @@ export async function startTask(
|
|||||||
task: false,
|
task: false,
|
||||||
call_omo_agent: true,
|
call_omo_agent: true,
|
||||||
question: false,
|
question: false,
|
||||||
...getAgentToolRestrictions(input.agent),
|
...getAgentToolRestrictions(normalizedAgent),
|
||||||
},
|
},
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,63 +433,89 @@ describe("boulder-state", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("getPlanProgress", () => {
|
describe("getPlanProgress", () => {
|
||||||
test("should count completed and uncompleted checkboxes", () => {
|
test("should count only top-level tasks under TODOs and Final Verification Wave sections", () => {
|
||||||
// given - plan file with checkboxes
|
// given - plan with top-level tasks in tracked sections
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
writeFileSync(planPath, `# Plan
|
writeFileSync(planPath, `# Plan
|
||||||
- [ ] Task 1
|
|
||||||
- [x] Task 2
|
## TODOs
|
||||||
- [ ] Task 3
|
- [ ] 1. Task 1
|
||||||
- [X] Task 4
|
- [x] 2. Task 2
|
||||||
|
- [ ] 3. Task 3
|
||||||
|
- [X] 4. Task 4
|
||||||
|
|
||||||
|
## Final Verification Wave
|
||||||
|
- [ ] F1. Final review
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const progress = getPlanProgress(planPath)
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(progress.total).toBe(4)
|
expect(progress.total).toBe(5)
|
||||||
expect(progress.completed).toBe(2)
|
expect(progress.completed).toBe(2)
|
||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should count space-indented unchecked checkbox", () => {
|
test("should ignore nested Acceptance Criteria checkboxes under TODOs (issue #3066)", () => {
|
||||||
// given - plan file with a two-space indented checkbox
|
// given - plan with 9 completed top-level tasks and unchecked nested acceptance criteria
|
||||||
const planPath = join(TEST_DIR, "space-indented-plan.md")
|
const planPath = join(TEST_DIR, "issue-3066-plan.md")
|
||||||
writeFileSync(planPath, `# Plan
|
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
|
// when
|
||||||
const progress = getPlanProgress(planPath)
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(progress.total).toBe(1)
|
expect(progress.total).toBe(10)
|
||||||
expect(progress.completed).toBe(0)
|
expect(progress.completed).toBe(9)
|
||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should count tab-indented unchecked checkbox", () => {
|
test("should ignore checkboxes outside TODOs and Final Verification Wave sections", () => {
|
||||||
// given - plan file with a tab-indented checkbox
|
// given - plan with checkboxes in Work Objectives, Success Criteria, and other sections
|
||||||
const planPath = join(TEST_DIR, "tab-indented-plan.md")
|
const planPath = join(TEST_DIR, "ignore-other-sections-plan.md")
|
||||||
writeFileSync(planPath, `# Plan
|
writeFileSync(planPath, `# Plan
|
||||||
- [ ] tab-indented task
|
|
||||||
`)
|
|
||||||
|
|
||||||
// when
|
## Work Objectives
|
||||||
const progress = getPlanProgress(planPath)
|
|
||||||
|
|
||||||
// then
|
### Definition of Done
|
||||||
expect(progress.total).toBe(1)
|
- [ ] Verifiable condition with command
|
||||||
expect(progress.completed).toBe(0)
|
|
||||||
expect(progress.isComplete).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should count mixed top-level checked and indented unchecked checkboxes", () => {
|
## TODOs
|
||||||
// given - plan file with checked top-level and unchecked indented task
|
- [x] 1. Real task one
|
||||||
const planPath = join(TEST_DIR, "mixed-indented-plan.md")
|
- [ ] 2. Real task two
|
||||||
writeFileSync(planPath, `# Plan
|
|
||||||
- [x] top-level completed task
|
## Success Criteria
|
||||||
- [ ] nested unchecked task
|
|
||||||
|
### Final Checklist
|
||||||
|
- [ ] All Must Have present
|
||||||
|
- [ ] All Must NOT Have absent
|
||||||
|
- [ ] All tests pass
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -501,11 +527,14 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should count space-indented completed checkbox", () => {
|
test("should ignore indented checkboxes under top-level tasks", () => {
|
||||||
// given - plan file with a two-space indented completed checkbox
|
// given - plan with indented unchecked nested checkboxes
|
||||||
const planPath = join(TEST_DIR, "indented-completed-plan.md")
|
const planPath = join(TEST_DIR, "nested-indented-plan.md")
|
||||||
writeFileSync(planPath, `# Plan
|
writeFileSync(planPath, `# Plan
|
||||||
- [x] indented completed task
|
|
||||||
|
## TODOs
|
||||||
|
- [x] 1. top-level completed task
|
||||||
|
- [ ] nested unchecked task
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -517,20 +546,67 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.isComplete).toBe(true)
|
expect(progress.isComplete).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should return isComplete true when all checked", () => {
|
test("should require proper task label format in TODOs", () => {
|
||||||
// given - all tasks completed
|
// given - plan with malformed labels (no numeric prefix)
|
||||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
const planPath = join(TEST_DIR, "malformed-labels-plan.md")
|
||||||
writeFileSync(planPath, `# Plan
|
writeFileSync(planPath, `# Plan
|
||||||
- [x] Task 1
|
|
||||||
- [X] Task 2
|
## TODOs
|
||||||
|
- [ ] no number prefix
|
||||||
|
- [x] 1. Valid numbered task
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const progress = getPlanProgress(planPath)
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
// then
|
// 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.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)
|
expect(progress.isComplete).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -555,6 +631,84 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.total).toBe(0)
|
expect(progress.total).toBe(0)
|
||||||
expect(progress.isComplete).toBe(true)
|
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", () => {
|
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.
|
* 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 {
|
export function getPlanProgress(planPath: string): PlanProgress {
|
||||||
if (!existsSync(planPath)) {
|
if (!existsSync(planPath)) {
|
||||||
@@ -206,24 +223,89 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(planPath, "utf-8")
|
const content = readFileSync(planPath, "utf-8")
|
||||||
|
const lines = content.split(/\r?\n/)
|
||||||
// Match markdown checkboxes: - [ ] or - [x] or - [X]
|
|
||||||
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
|
|
||||||
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
|
|
||||||
|
|
||||||
const total = uncheckedMatches.length + checkedMatches.length
|
// Check if the plan has structured sections (## TODOs / ## Final Verification Wave)
|
||||||
const completed = checkedMatches.length
|
const hasStructuredSections = lines.some(
|
||||||
|
(line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
if (hasStructuredSections) {
|
||||||
total,
|
// Structured plan: only count top-level checkboxes with numbered labels
|
||||||
completed,
|
// under ## TODOs and ## Final Verification Wave sections
|
||||||
isComplete: total > 0 && completed === total,
|
return getStructuredPlanProgress(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple plan: count all top-level checkboxes anywhere
|
||||||
|
return getSimplePlanProgress(content)
|
||||||
} catch {
|
} catch {
|
||||||
return { total: 0, completed: 0, isComplete: true }
|
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.
|
* 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 FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||||
|
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
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>\`
|
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
|
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
|
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
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,32 @@ describe("expandEnvVars", () => {
|
|||||||
expect(expanded).toBe("user-approved")
|
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", () => {
|
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,
|
isSensitiveMcpEnvVar,
|
||||||
} from "./configure-allowed-env-vars"
|
} 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(
|
return value.replace(
|
||||||
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
||||||
(_, varName: string, defaultValue?: string) => {
|
(_, varName: string, defaultValue?: string) => {
|
||||||
if (!isAllowedMcpEnvVar(varName)) {
|
if (!trusted && !isAllowedMcpEnvVar(varName)) {
|
||||||
const isSensitive = isSensitiveMcpEnvVar(varName)
|
const isSensitive = isSensitiveMcpEnvVar(varName)
|
||||||
const reason = isSensitive ? "sensitive variable" : "not in allowlist"
|
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 (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)) {
|
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") {
|
if (typeof obj === "object") {
|
||||||
const result: Record<string, unknown> = {}
|
const result: Record<string, unknown> = {}
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
result[key] = expandEnvVarsInObject(value)
|
result[key] = expandEnvVarsInObject(value, options)
|
||||||
}
|
}
|
||||||
return result as T
|
return result as T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { join } from "node:path"
|
|||||||
|
|
||||||
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
|
||||||
function createTemporaryDirectory(prefix: string): string {
|
function createTemporaryDirectory(prefix: string): string {
|
||||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||||
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
|
|||||||
return directory
|
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", () => {
|
describe("discoverInstalledPlugins", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.module("../../shared/logger", () => ({
|
mock.module("../../shared/logger", () => ({
|
||||||
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.cwd() !== originalCwd) {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
|
||||||
for (const directory of temporaryDirectories.splice(0)) {
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
rmSync(directory, { recursive: true, force: true })
|
rmSync(directory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
expect(discovered.plugins).toHaveLength(1)
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
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 { basename, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||||
import type {
|
import type {
|
||||||
InstalledPluginsDatabase,
|
InstalledPluginsDatabase,
|
||||||
InstalledPluginEntryV3,
|
InstalledPluginEntryV3,
|
||||||
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
|
|||||||
installedAt: entry.lastUpdated,
|
installedAt: entry.lastUpdated,
|
||||||
lastUpdated: entry.lastUpdated,
|
lastUpdated: entry.lastUpdated,
|
||||||
gitCommitSha: entry.gitCommitSha,
|
gitCommitSha: entry.gitCommitSha,
|
||||||
|
projectPath: entry.projectPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||||
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||||
if (!installation) continue
|
if (!installation) continue
|
||||||
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
continue
|
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
|
const { installPath, scope, version } = installation
|
||||||
|
|
||||||
if (!existsSync(installPath)) {
|
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
|
lastUpdated: string
|
||||||
gitCommitSha?: string
|
gitCommitSha?: string
|
||||||
isLocal?: boolean
|
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
|
installPath: string
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
gitCommitSha?: 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,
|
getMainSessionID,
|
||||||
registerAgentName,
|
registerAgentName,
|
||||||
isAgentRegistered,
|
isAgentRegistered,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
_resetForTesting,
|
_resetForTesting,
|
||||||
} from "./state"
|
} from "./state"
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ describe("claude-code-session-state", () => {
|
|||||||
test("should store agent for session", () => {
|
test("should store agent for session", () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = "test-session-1"
|
const sessionID = "test-session-1"
|
||||||
const agent = "Prometheus (Planner)"
|
const agent = "Prometheus - Plan Builder"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
setSessionAgent(sessionID, agent)
|
setSessionAgent(sessionID, agent)
|
||||||
@@ -52,13 +53,13 @@ describe("claude-code-session-state", () => {
|
|||||||
test("should NOT overwrite existing agent (first-write wins)", () => {
|
test("should NOT overwrite existing agent (first-write wins)", () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = "test-session-1"
|
const sessionID = "test-session-1"
|
||||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||||
|
|
||||||
// when - try to overwrite
|
// when - try to overwrite
|
||||||
setSessionAgent(sessionID, "sisyphus")
|
setSessionAgent(sessionID, "sisyphus")
|
||||||
|
|
||||||
// then - first agent preserved
|
// then - first agent preserved
|
||||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should return undefined for unknown session", () => {
|
test("should return undefined for unknown session", () => {
|
||||||
@@ -73,7 +74,7 @@ describe("claude-code-session-state", () => {
|
|||||||
test("should overwrite existing agent", () => {
|
test("should overwrite existing agent", () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = "test-session-1"
|
const sessionID = "test-session-1"
|
||||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||||
|
|
||||||
// when - force update
|
// when - force update
|
||||||
updateSessionAgent(sessionID, "sisyphus")
|
updateSessionAgent(sessionID, "sisyphus")
|
||||||
@@ -99,8 +100,8 @@ describe("claude-code-session-state", () => {
|
|||||||
test("should remove agent from session", () => {
|
test("should remove agent from session", () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = "test-session-1"
|
const sessionID = "test-session-1"
|
||||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
setSessionAgent(sessionID, "Prometheus - Plan Builder")
|
||||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
clearSessionAgent(sessionID)
|
clearSessionAgent(sessionID)
|
||||||
@@ -140,6 +141,15 @@ describe("claude-code-session-state", () => {
|
|||||||
expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true)
|
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("#given atlas display name with zero-width prefix", () => {
|
||||||
describe("#when checking registration without the zero-width prefix", () => {
|
describe("#when checking registration without the zero-width prefix", () => {
|
||||||
test("#then it treats the display name as registered", () => {
|
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", () => {
|
test("should correctly identify Prometheus agent for permission checks", () => {
|
||||||
// given - Prometheus session
|
// given - Prometheus session
|
||||||
const sessionID = "test-prometheus-session"
|
const sessionID = "test-prometheus-session"
|
||||||
const prometheusAgent = "Prometheus (Planner)"
|
const prometheusAgent = "Prometheus - Plan Builder"
|
||||||
|
|
||||||
// when - agent is set (simulating chat.message hook)
|
// when - agent is set (simulating chat.message hook)
|
||||||
setSessionAgent(sessionID, prometheusAgent)
|
setSessionAgent(sessionID, prometheusAgent)
|
||||||
|
|
||||||
// then - getSessionAgent returns correct agent for prometheus-md-only hook
|
// then - getSessionAgent returns correct agent for prometheus-md-only hook
|
||||||
const agent = getSessionAgent(sessionID)
|
const agent = getSessionAgent(sessionID)
|
||||||
expect(agent).toBe("Prometheus (Planner)")
|
expect(agent).toBe("Prometheus - Plan Builder")
|
||||||
expect(["Prometheus (Planner)"].includes(agent!)).toBe(true)
|
expect(["Prometheus - Plan Builder"].includes(agent!)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should return undefined when agent not set (bug scenario)", () => {
|
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 registeredAgentNames = new Set<string>()
|
||||||
|
const registeredAgentAliases = new Map<string, string>()
|
||||||
|
|
||||||
const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
|
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 {
|
export function registerAgentName(name: string): void {
|
||||||
const normalizedName = normalizeRegisteredAgentName(name)
|
const normalizedName = normalizeRegisteredAgentName(name)
|
||||||
registeredAgentNames.add(normalizedName)
|
registeredAgentNames.add(normalizedName)
|
||||||
|
if (!registeredAgentAliases.has(normalizedName)) {
|
||||||
|
registeredAgentAliases.set(normalizedName, name)
|
||||||
|
}
|
||||||
|
|
||||||
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
|
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
|
||||||
if (configKey !== normalizedName) {
|
if (configKey !== normalizedName) {
|
||||||
registeredAgentNames.add(configKey)
|
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))
|
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 */
|
/** @internal For testing only */
|
||||||
export function _resetForTesting(): void {
|
export function _resetForTesting(): void {
|
||||||
_mainSessionID = undefined
|
_mainSessionID = undefined
|
||||||
@@ -46,6 +62,7 @@ export function _resetForTesting(): void {
|
|||||||
syncSubagentSessions.clear()
|
syncSubagentSessions.clear()
|
||||||
sessionAgentMap.clear()
|
sessionAgentMap.clear()
|
||||||
registeredAgentNames.clear()
|
registeredAgentNames.clear()
|
||||||
|
registeredAgentAliases.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionAgentMap = new Map<string, string>()
|
const sessionAgentMap = new Map<string, string>()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
generatePartId,
|
generatePartId,
|
||||||
injectHookMessage,
|
injectHookMessage,
|
||||||
} from "./injector"
|
} from "./injector"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
||||||
|
|
||||||
//#region Mocks
|
//#region Mocks
|
||||||
@@ -53,6 +54,7 @@ function createMockClient(messages: Array<{
|
|||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
time?: { created?: number }
|
time?: { created?: number }
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}>): {
|
}>): {
|
||||||
session: {
|
session: {
|
||||||
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
||||||
@@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
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", () => {
|
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
||||||
@@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
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", () => {
|
describe("findFirstMessageWithAgentFromSDK", () => {
|
||||||
@@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
|||||||
expect(result).toBe("earliest-agent")
|
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 () => {
|
it("skips messages without agent field", async () => {
|
||||||
const mockClient = createMockClient([
|
const mockClient = createMockClient([
|
||||||
{ info: {} },
|
{ info: {} },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||||
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
export interface StoredMessage {
|
export interface StoredMessage {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -32,6 +33,7 @@ interface SDKMessage {
|
|||||||
created?: number
|
created?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const processPrefix = randomBytes(4).toString("hex")
|
const processPrefix = randomBytes(4).toString("hex")
|
||||||
@@ -39,6 +41,10 @@ let messageCounter = 0
|
|||||||
let partCounter = 0
|
let partCounter = 0
|
||||||
|
|
||||||
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(msg)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = msg.info
|
const info = msg.info
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
|
||||||
@@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
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,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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))
|
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
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) {
|
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of messages) {
|
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)) {
|
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
@@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
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,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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))
|
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent) {
|
if (entry.msg.agent) {
|
||||||
return 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 { dirname, join } from "node:path"
|
||||||
import { getOpenCodeConfigDir } from "../../shared"
|
import { getOpenCodeConfigDir } from "../../shared"
|
||||||
|
|
||||||
@@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean {
|
|||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||||
chmodSync(filePath, 0o600)
|
writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||||
|
chmodSync(tempPath, 0o600)
|
||||||
|
renameSync(tempPath, filePath)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
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",
|
serverName: "race-server",
|
||||||
skillName: "race-skill",
|
skillName: "race-skill",
|
||||||
sessionID,
|
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: {
|
export async function getOrCreateClient(params: {
|
||||||
state: SkillMcpManagerState
|
state: SkillMcpManagerState
|
||||||
clientKey: string
|
clientKey: string
|
||||||
@@ -38,7 +40,8 @@ export async function getOrCreateClient(params: {
|
|||||||
return pending
|
return pending
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedConfig = expandEnvVarsInObject(config)
|
const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
|
||||||
|
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
|
||||||
let currentConnectionPromise!: Promise<Client>
|
let currentConnectionPromise!: Promise<Client>
|
||||||
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
||||||
currentConnectionPromise = (async () => {
|
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",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "my-mcp",
|
serverName: "my-mcp",
|
||||||
skillName: "data-skill",
|
skillName: "data-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "custom-server",
|
serverName: "custom-server",
|
||||||
skillName: "custom-skill",
|
skillName: "custom-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {}
|
const config: ClaudeCodeMcpServer = {}
|
||||||
|
|
||||||
@@ -112,6 +115,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "http-server",
|
serverName: "http-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "http",
|
type: "http",
|
||||||
@@ -130,6 +134,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "sse-server",
|
serverName: "sse-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "sse",
|
type: "sse",
|
||||||
@@ -148,6 +153,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "inferred-http",
|
serverName: "inferred-http",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -165,6 +171,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "stdio-server",
|
serverName: "stdio-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -184,6 +191,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "inferred-stdio",
|
serverName: "inferred-stdio",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -202,6 +210,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "mixed-config",
|
serverName: "mixed-config",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -224,6 +233,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "bad-url-server",
|
serverName: "bad-url-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "http",
|
type: "http",
|
||||||
@@ -242,6 +252,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "http-error-server",
|
serverName: "http-error-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://nonexistent.example.com/mcp",
|
url: "https://nonexistent.example.com/mcp",
|
||||||
@@ -259,6 +270,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "hint-server",
|
serverName: "hint-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://nonexistent.example.com/mcp",
|
url: "https://nonexistent.example.com/mcp",
|
||||||
@@ -276,6 +288,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "mock-test-server",
|
serverName: "mock-test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -302,6 +315,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "missing-command",
|
serverName: "missing-command",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -320,6 +334,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "nonexistent-command-xyz",
|
command: "nonexistent-command-xyz",
|
||||||
@@ -338,6 +353,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
command: "nonexistent-command",
|
command: "nonexistent-command",
|
||||||
@@ -358,11 +374,13 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "server1",
|
serverName: "server1",
|
||||||
skillName: "skill1",
|
skillName: "skill1",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const session2Info: SkillMcpClientInfo = {
|
const session2Info: SkillMcpClientInfo = {
|
||||||
serverName: "server1",
|
serverName: "server1",
|
||||||
skillName: "skill1",
|
skillName: "skill1",
|
||||||
sessionID: "session-2",
|
sessionID: "session-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -396,6 +414,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "signal-server",
|
serverName: "signal-server",
|
||||||
skillName: "signal-skill",
|
skillName: "signal-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -423,11 +442,12 @@ describe("SkillMcpManager", () => {
|
|||||||
describe("isConnected", () => {
|
describe("isConnected", () => {
|
||||||
it("returns false for unconnected server", () => {
|
it("returns false for unconnected server", () => {
|
||||||
// given
|
// given
|
||||||
const info: SkillMcpClientInfo = {
|
const info: SkillMcpClientInfo = {
|
||||||
serverName: "unknown",
|
serverName: "$1",
|
||||||
skillName: "test",
|
skillName: "$2",
|
||||||
sessionID: "session-1",
|
sessionID: "$3",
|
||||||
}
|
scope: "builtin",
|
||||||
|
}
|
||||||
|
|
||||||
// when / #then
|
// when / #then
|
||||||
expect(manager.isConnected(info)).toBe(false)
|
expect(manager.isConnected(info)).toBe(false)
|
||||||
@@ -448,6 +468,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const configWithoutEnv: ClaudeCodeMcpServer = {
|
const configWithoutEnv: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -471,6 +492,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "test-server",
|
serverName: "test-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-2",
|
sessionID: "session-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const configWithEnv: ClaudeCodeMcpServer = {
|
const configWithEnv: ClaudeCodeMcpServer = {
|
||||||
command: "node",
|
command: "node",
|
||||||
@@ -498,6 +520,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "auth-server",
|
serverName: "auth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -526,6 +549,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-auth-server",
|
serverName: "no-auth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
@@ -546,6 +570,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "retry-server",
|
serverName: "retry-server",
|
||||||
skillName: "retry-skill",
|
skillName: "retry-skill",
|
||||||
sessionID: "session-retry-1",
|
sessionID: "session-retry-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -584,6 +609,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "fail-server",
|
serverName: "fail-server",
|
||||||
skillName: "fail-skill",
|
skillName: "fail-skill",
|
||||||
sessionID: "session-fail-1",
|
sessionID: "session-fail-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -615,6 +641,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "error-server",
|
serverName: "error-server",
|
||||||
skillName: "error-skill",
|
skillName: "error-skill",
|
||||||
sessionID: "session-error-1",
|
sessionID: "session-error-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
@@ -653,6 +680,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-server",
|
serverName: "oauth-server",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-1",
|
sessionID: "session-oauth-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -679,6 +707,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-no-token",
|
serverName: "oauth-no-token",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-2",
|
sessionID: "session-oauth-2",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -705,6 +734,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-with-headers",
|
serverName: "oauth-with-headers",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-3",
|
sessionID: "session-oauth-3",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -734,6 +764,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-refresh",
|
serverName: "oauth-refresh",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-refresh",
|
sessionID: "session-oauth-refresh",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -766,6 +797,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "oauth-refresh-fallback",
|
serverName: "oauth-refresh-fallback",
|
||||||
skillName: "oauth-skill",
|
skillName: "oauth-skill",
|
||||||
sessionID: "session-oauth-refresh-fallback",
|
sessionID: "session-oauth-refresh-fallback",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -799,6 +831,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-oauth-server",
|
serverName: "no-oauth-server",
|
||||||
skillName: "test-skill",
|
skillName: "test-skill",
|
||||||
sessionID: "session-no-oauth",
|
sessionID: "session-no-oauth",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -824,6 +857,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "stepup-server",
|
serverName: "stepup-server",
|
||||||
skillName: "stepup-skill",
|
skillName: "stepup-skill",
|
||||||
sessionID: "session-stepup-1",
|
sessionID: "session-stepup-1",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const config: ClaudeCodeMcpServer = {
|
const config: ClaudeCodeMcpServer = {
|
||||||
url: "https://mcp.example.com/mcp",
|
url: "https://mcp.example.com/mcp",
|
||||||
@@ -869,6 +903,7 @@ describe("SkillMcpManager", () => {
|
|||||||
serverName: "no-stepup-server",
|
serverName: "no-stepup-server",
|
||||||
skillName: "no-stepup-skill",
|
skillName: "no-stepup-skill",
|
||||||
sessionID: "session-no-stepup",
|
sessionID: "session-no-stepup",
|
||||||
|
scope: "builtin",
|
||||||
}
|
}
|
||||||
const context: SkillMcpServerContext = {
|
const context: SkillMcpServerContext = {
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
|||||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
|
import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
|
||||||
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
|
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
|
||||||
import { handleStepUpIfNeeded } from "./oauth-handler"
|
import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler"
|
||||||
import type {
|
import type {
|
||||||
OAuthProviderFactory,
|
OAuthProviderFactory,
|
||||||
SkillMcpClientInfo,
|
SkillMcpClientInfo,
|
||||||
@@ -110,6 +110,7 @@ export class SkillMcpManager {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
|
const refreshAttempted = new Set<string>()
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -130,6 +131,17 @@ export class SkillMcpManager {
|
|||||||
continue
|
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")) {
|
if (!errorMessage.includes("not connected")) {
|
||||||
throw lastError
|
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 type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
|
import { withRefreshMutex } from "../mcp-oauth/refresh-mutex"
|
||||||
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||||
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
||||||
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
import type { OAuthProviderFactory, OAuthProviderLike } from "./types"
|
||||||
@@ -52,14 +53,15 @@ export async function buildHttpRequestInit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData && isTokenExpired(tokenData)) {
|
if (tokenData && isTokenExpired(tokenData)) {
|
||||||
try {
|
|
||||||
tokenData = tokenData.refreshToken
|
|
||||||
? await provider.refresh(tokenData.refreshToken)
|
|
||||||
: await provider.login()
|
|
||||||
} catch {
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
tokenData = null
|
tokenData = null
|
||||||
}
|
}
|
||||||
@@ -116,3 +118,43 @@ export async function handleStepUpIfNeeded(params: {
|
|||||||
return false
|
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 type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||||
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
||||||
|
import { redactSensitiveData } from "./error-redaction"
|
||||||
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
|
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
|
||||||
|
|
||||||
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
|
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 errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
const fullCommand = `${command} ${args.join(" ")}`
|
||||||
|
const safeCommand = redactSensitiveData(fullCommand)
|
||||||
|
const safeErrorMessage = redactSensitiveData(errorMessage)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||||
`Command: ${command} ${args.join(" ")}\n` +
|
`Command: ${safeCommand}\n` +
|
||||||
`Reason: ${errorMessage}\n\n` +
|
`Reason: ${safeErrorMessage}\n\n` +
|
||||||
`Hints:\n` +
|
`Hints:\n` +
|
||||||
` - Ensure the command is installed and available in PATH\n` +
|
` - Ensure the command is installed and available in PATH\n` +
|
||||||
` - Check if the MCP server package exists\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 { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
import type { McpOAuthProvider } from "../mcp-oauth/provider"
|
import type { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||||
|
import type { SkillScope } from "../opencode-skill-loader/types"
|
||||||
|
|
||||||
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export interface SkillMcpClientInfo {
|
|||||||
serverName: string
|
serverName: string
|
||||||
skillName: string
|
skillName: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
scope?: SkillScope | "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillMcpServerContext {
|
export interface SkillMcpServerContext {
|
||||||
|
|||||||
@@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({
|
|||||||
mock.module("../../shared/tmux", () => ({
|
mock.module("../../shared/tmux", () => ({
|
||||||
isInsideTmux: mockIsInsideTmux,
|
isInsideTmux: mockIsInsideTmux,
|
||||||
getCurrentPaneId: mockGetCurrentPaneId,
|
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,
|
POLL_INTERVAL_BACKGROUND_MS: 10,
|
||||||
SESSION_READY_POLL_INTERVAL_MS: 10,
|
SESSION_READY_POLL_INTERVAL_MS: 10,
|
||||||
SESSION_READY_TIMEOUT_MS: 50,
|
SESSION_READY_TIMEOUT_MS: 50,
|
||||||
SESSION_MISSING_GRACE_MS: 1_000,
|
SESSION_MISSING_GRACE_MS: 1_000,
|
||||||
|
SESSION_TIMEOUT_MS: 600_000,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
afterAll(() => { mock.restore() })
|
afterAll(() => { mock.restore() })
|
||||||
|
|||||||
@@ -121,4 +121,64 @@ describe("injectBoulderContinuation", () => {
|
|||||||
expect(result).toBe("skipped_agent_unavailable")
|
expect(result).toBe("skipped_agent_unavailable")
|
||||||
expect(promptAsyncMock).not.toHaveBeenCalled()
|
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 { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
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 { log } from "../../shared/logger"
|
||||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
@@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: {
|
|||||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||||
preferredSessionContext +
|
preferredSessionContext +
|
||||||
worktreeContext
|
worktreeContext
|
||||||
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
|
const continuationAgent = resolveRegisteredAgentName(
|
||||||
|
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||||
|
)
|
||||||
|
|
||||||
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
||||||
@@ -71,12 +76,18 @@ export async function injectBoulderContinuation(input: {
|
|||||||
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||||
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
|
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
|
||||||
|
|
||||||
await ctx.client.session.promptAsync({
|
const launchModel = promptContext.model
|
||||||
path: { id: sessionID },
|
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
|
||||||
body: {
|
: undefined
|
||||||
agent: continuationAgent,
|
const launchVariant = promptContext.model?.variant
|
||||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
|
||||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
await ctx.client.session.promptAsync({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
agent: continuationAgent,
|
||||||
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
|
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||||
parts: [createInternalAgentTextPart(prompt)],
|
parts: [createInternalAgentTextPart(prompt)],
|
||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ export async function resolveRecentPromptContextForSession(
|
|||||||
const model = info?.model
|
const model = info?.model
|
||||||
const tools = normalizePromptTools(info?.tools)
|
const tools = normalizePromptTools(info?.tools)
|
||||||
if (model?.providerID && model?.modelID) {
|
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) {
|
if (info?.providerID && info?.modelID) {
|
||||||
@@ -63,7 +70,14 @@ export async function resolveRecentPromptContextForSession(
|
|||||||
if (!model?.providerID || !model?.modelID) {
|
if (!model?.providerID || !model?.modelID) {
|
||||||
return { tools }
|
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(
|
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 { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
const testDirs: string[] = []
|
const testDirs: string[] = []
|
||||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
||||||
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toBe("atlas")
|
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")
|
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 () => {
|
test("returns null instead of throwing when SQLite message lookup fails", async () => {
|
||||||
// given
|
// given
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessagesClient = {
|
type SessionMessagesClient = {
|
||||||
session: {
|
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 {
|
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||||
try {
|
try {
|
||||||
const messages = readdirSync(messageDir)
|
const messages = readdirSync(messageDir)
|
||||||
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
.map((fileName) => {
|
.map((fileName) => {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
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 {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
|
id: parsed.id,
|
||||||
agent: parsed.agent,
|
agent: parsed.agent,
|
||||||
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
|
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
|
||||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
|
||||||
|
|
||||||
for (const message of messages) {
|
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()
|
return message.agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
|
|||||||
if (isSqliteBackend() && client) {
|
if (isSqliteBackend() && client) {
|
||||||
try {
|
try {
|
||||||
const response = await client.session.messages({ path: { id: sessionID } })
|
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,
|
preferResponseOnMissingData: true,
|
||||||
}).sort((left, right) => {
|
}).sort((left, right) => {
|
||||||
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
|
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) {
|
for (const message of messages) {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const agent = message.info?.agent
|
const agent = message.info?.agent
|
||||||
if (typeof agent === "string" && !isCompactionAgent(agent)) {
|
if (typeof agent === "string") {
|
||||||
return agent.toLowerCase()
|
return agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AgentOverrides } from "../../config"
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { TopLevelTaskRef } from "../../features/boulder-state"
|
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 {
|
export interface AtlasHookOptions {
|
||||||
directory: string
|
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 { fileURLToPath } from "node:url"
|
||||||
import { log } from "../../../shared/logger"
|
import { log } from "../../../shared/logger"
|
||||||
import type { PackageJson } from "../types"
|
import type { PackageJson } from "../types"
|
||||||
import { INSTALLED_PACKAGE_JSON } from "../constants"
|
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
|
||||||
import { findPackageJsonUp } from "./package-json-locator"
|
import { findPackageJsonUp } from "./package-json-locator"
|
||||||
|
|
||||||
export function getCachedVersion(): string | null {
|
export function getCachedVersion(): string | null {
|
||||||
try {
|
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
|
||||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
try {
|
||||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
if (fs.existsSync(candidate)) {
|
||||||
const pkg = JSON.parse(content) as PackageJson
|
const content = fs.readFileSync(candidate, "utf-8")
|
||||||
if (pkg.version) return pkg.version
|
const pkg = JSON.parse(content) as PackageJson
|
||||||
|
if (pkg.version) return pkg.version
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; try next candidate
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import type { OpencodeConfig } from "../types"
|
import type { OpencodeConfig } from "../types"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
|
||||||
import { getConfigPaths } from "./config-paths"
|
import { getConfigPaths } from "./config-paths"
|
||||||
import { stripJsonComments } from "./jsonc-strip"
|
import { stripJsonComments } from "./jsonc-strip"
|
||||||
|
|
||||||
@@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null {
|
|||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
for (const entry of plugins) {
|
for (const entry of plugins) {
|
||||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
if (!entry.startsWith("file://")) continue
|
||||||
try {
|
if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue
|
||||||
return fileURLToPath(entry)
|
try {
|
||||||
} catch {
|
return fileURLToPath(entry)
|
||||||
return entry.replace("file://", "")
|
} catch {
|
||||||
}
|
return entry.replace("file://", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 fs from "node:fs"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import type { PackageJson } from "../types"
|
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 {
|
export function findPackageJsonUp(startPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||||
const pkg = JSON.parse(content) as PackageJson
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { spawnSync } from "node:child_process"
|
|||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import * as os from "node:os"
|
import * as os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
|
import { PACKAGE_NAME } from "../constants"
|
||||||
const PACKAGE_NAME = "oh-my-openagent"
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
type PluginEntryResult = {
|
type PluginEntryResult = {
|
||||||
entry: string
|
entry: string
|
||||||
@@ -121,6 +121,64 @@ describe("findPluginEntry", () => {
|
|||||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
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 () => {
|
test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => {
|
||||||
// #given profile-specific user config after module import
|
// #given profile-specific user config after module import
|
||||||
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types"
|
|||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
import { getConfigPaths } from "./config-paths"
|
import { getConfigPaths } from "./config-paths"
|
||||||
import { stripJsonComments } from "./jsonc-strip"
|
import { stripJsonComments } from "./jsonc-strip"
|
||||||
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
export interface PluginEntryInfo {
|
export interface PluginEntryInfo {
|
||||||
entry: string
|
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 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 {
|
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||||
for (const configPath of getConfigPaths(directory)) {
|
for (const configPath of getConfigPaths(directory)) {
|
||||||
@@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
|||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
for (const entry of plugins) {
|
for (const entry of plugins) {
|
||||||
if (entry === PACKAGE_NAME) {
|
for (const pluginName of MATCH_PLUGIN_NAMES) {
|
||||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
if (entry === pluginName) {
|
||||||
}
|
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
}
|
||||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
if (entry.startsWith(`${pluginName}@`)) {
|
||||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
const pinnedVersion = entry.slice(pluginName.length + 1)
|
||||||
return { entry, isPinned, pinnedVersion, configPath }
|
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||||
|
return { entry, isPinned, pinnedVersion, configPath }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user