diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af24ea533..c2d72bc21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" - name: Run tests - run: bun test + run: bun run script/run-ci-tests.ts typecheck: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 65f97134a..7415257a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" - name: Run tests - run: bun test + run: bun run script/run-ci-tests.ts typecheck: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 397b18610..86c7d8245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # oh-my-opencode — O P E N C O D E Plugin -**Generated:** 2026-04-05 | **Commit:** c9be5bb51 | **Branch:** dev +**Generated:** 2026-04-08 | **Commit:** 4f196f49 | **Branch:** dev ## OVERVIEW diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index b5310229e..607988931 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -1,5 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", + "title": "Oh My OpenCode Configuration", + "description": "Configuration schema for oh-my-opencode plugin", "type": "object", "properties": { "$schema": { @@ -6068,8 +6071,5 @@ "required": [ "git_master" ], - "additionalProperties": false, - "$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", - "title": "Oh My OpenCode Configuration", - "description": "Configuration schema for oh-my-opencode plugin" + "additionalProperties": false } \ No newline at end of file diff --git a/bun.lock b/bun.lock index ef331fc85..b4e9f12f5 100644 --- a/bun.lock +++ b/bun.lock @@ -10,8 +10,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", - "@opencode-ai/plugin": "^1.2.24", - "@opencode-ai/sdk": "^1.2.24", + "@opencode-ai/plugin": "^1.4.0", + "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", "detect-libc": "^2.0.0", "diff": "^8.0.3", @@ -20,8 +20,7 @@ "picocolors": "^1.1.1", "picomatch": "^4.0.2", "vscode-jsonrpc": "^8.2.0", - "zod": "^3.24.0", - "zod-to-json-schema": "^3.25.1", + "zod": "^4.3.0", }, "devDependencies": { "@types/js-yaml": "^4.0.9", @@ -30,17 +29,17 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.15.3", - "oh-my-opencode-darwin-x64": "3.15.3", - "oh-my-opencode-darwin-x64-baseline": "3.15.3", - "oh-my-opencode-linux-arm64": "3.15.3", - "oh-my-opencode-linux-arm64-musl": "3.15.3", - "oh-my-opencode-linux-x64": "3.15.3", - "oh-my-opencode-linux-x64-baseline": "3.15.3", - "oh-my-opencode-linux-x64-musl": "3.15.3", - "oh-my-opencode-linux-x64-musl-baseline": "3.15.3", - "oh-my-opencode-windows-x64": "3.15.3", - "oh-my-opencode-windows-x64-baseline": "3.15.3", + "oh-my-opencode-darwin-arm64": "3.16.0", + "oh-my-opencode-darwin-x64": "3.16.0", + "oh-my-opencode-darwin-x64-baseline": "3.16.0", + "oh-my-opencode-linux-arm64": "3.16.0", + "oh-my-opencode-linux-arm64-musl": "3.16.0", + "oh-my-opencode-linux-x64": "3.16.0", + "oh-my-opencode-linux-x64-baseline": "3.16.0", + "oh-my-opencode-linux-x64-musl": "3.16.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.16.0", + "oh-my-opencode-windows-x64": "3.16.0", + "oh-my-opencode-windows-x64-baseline": "3.16.0", }, }, }, @@ -49,9 +48,6 @@ "@ast-grep/napi", "@code-yeongyu/comment-checker", ], - "overrides": { - "@opencode-ai/sdk": "^1.2.24", - }, "packages": { "@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="], @@ -99,9 +95,9 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.24", "", { "dependencies": { "@opencode-ai/sdk": "1.2.24", "zod": "4.1.8" } }, "sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -239,28 +235,6 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.15.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-FApyQE45gv3VFwS/7iLS1/84v4iTX6BIVNcYYU2faqPazcZkvenkMbtxuWRfohQyZ1lhADopnjUcqOdcKjLDGQ=="], - - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-h4fr0/myoyvvytdizfLNQgRAWK+hw+1tW32rgL7ENLv1JQ8ChXHnHKEQ2saEqGfn1SuXvA0xUTsFMYR8q3mnbA=="], - - "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zhi5xGcEhirHcx95kZtABYlIdSt6a5L5+T+exR4Kcnu+KR1mJ6li9n3UBIiW8eVgDz2ls7W25ePD78xRlqnxlg=="], - - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+lDsQMPfXGCrwe9vqHdmp1tCJ8PV+5OkKueVorRwXNfiZNOW3848TKxtW3QdkKopiBKejEaDfyu/IGSgWQ/iyQ=="], - - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-cokhNYK+dBVPRmZ2bYd3ZNp7dSGZdko77qUaeb0jjALFWkNzzmFgOV0spgOGZ3iS+yMS1XjAheTo5Qswh0capQ=="], - - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8+57NMUwdcc2DZGX6KlNb1EchTB6xmwiiHcRhFZpYiAB1GCUFNeWihq3D7r5GUtOs0zQYWUT/F1Rj2nzBxuy+A=="], - - "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1awTpjU8m1cLF+GiiT7BuK5+y+WvTZwAaBZzYrJBzldiqdqMGJVYaH/uLiKt6CdZ0T6jh0zR/v85VFZIaXRusQ=="], - - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-WhmJ9ZwXxe3Nv0sVnFN3ibykie1JDiXthOmErhtKbcAL9V25IDYSbTcjxY2jUq0rNr4PeTvBva+WkMW4k9438w=="], - - "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Gx2YitS/Ydg1XdwZMAH186ABvHGPlnuVA/1j7nGdARIwNM/xz6bZRq+kaeMmlj2N1U63unMOHe1ibE6nL1oZSw=="], - - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-Q6xskcBlBqUT77OK+7oHID9McrHu6t5+P/YCaDU/zLvr1T8M0Z5WgakM5hRsqCI8e4P1NEX6wHtwQNbVfUgo1w=="], - - "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-2BlXtH+DrSRPFGEOtfY1mlROOXFeWQbG/EpDw0JD27s7QQOkShaDff8Vc48PnmD1H8vW4d7/o/eP8jJPPjGQ0w=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -331,12 +305,10 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/package.json b/package.json index 7cf39b8a0..f85be03a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.15.3", + "version": "3.16.0", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "./dist/index.js", "types": "dist/index.d.ts", @@ -59,8 +59,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", - "@opencode-ai/plugin": "^1.2.24", - "@opencode-ai/sdk": "^1.2.24", + "@opencode-ai/plugin": "^1.4.0", + "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", "detect-libc": "^2.0.0", "diff": "^8.0.3", @@ -69,8 +69,7 @@ "picocolors": "^1.1.1", "picomatch": "^4.0.2", "vscode-jsonrpc": "^8.2.0", - "zod-to-json-schema": "^3.25.1", - "zod": "^3.24.0" + "zod": "^4.3.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", @@ -79,21 +78,19 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.15.3", - "oh-my-opencode-darwin-x64": "3.15.3", - "oh-my-opencode-darwin-x64-baseline": "3.15.3", - "oh-my-opencode-linux-arm64": "3.15.3", - "oh-my-opencode-linux-arm64-musl": "3.15.3", - "oh-my-opencode-linux-x64": "3.15.3", - "oh-my-opencode-linux-x64-baseline": "3.15.3", - "oh-my-opencode-linux-x64-musl": "3.15.3", - "oh-my-opencode-linux-x64-musl-baseline": "3.15.3", - "oh-my-opencode-windows-x64": "3.15.3", - "oh-my-opencode-windows-x64-baseline": "3.15.3" - }, - "overrides": { - "@opencode-ai/sdk": "^1.2.24" + "oh-my-opencode-darwin-arm64": "3.16.0", + "oh-my-opencode-darwin-x64": "3.16.0", + "oh-my-opencode-darwin-x64-baseline": "3.16.0", + "oh-my-opencode-linux-arm64": "3.16.0", + "oh-my-opencode-linux-arm64-musl": "3.16.0", + "oh-my-opencode-linux-x64": "3.16.0", + "oh-my-opencode-linux-x64-baseline": "3.16.0", + "oh-my-opencode-linux-x64-musl": "3.16.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.16.0", + "oh-my-opencode-windows-x64": "3.16.0", + "oh-my-opencode-windows-x64-baseline": "3.16.0" }, + "overrides": {}, "trustedDependencies": [ "@ast-grep/cli", "@ast-grep/napi", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index f14b36ad8..bea7332ba 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-arm64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64-baseline/package.json b/packages/darwin-x64-baseline/package.json index 8cb655c1f..2dc63284a 100644 --- a/packages/darwin-x64-baseline/package.json +++ b/packages/darwin-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index ebe5fb016..ca47167b4 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json index 9db05776c..b69025a4e 100644 --- a/packages/linux-arm64-musl/package.json +++ b/packages/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64-musl", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index ba10cc22f..d1ed90d58 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-baseline/package.json b/packages/linux-x64-baseline/package.json index c9a321390..210556ed2 100644 --- a/packages/linux-x64-baseline/package.json +++ b/packages/linux-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl-baseline/package.json b/packages/linux-x64-musl-baseline/package.json index c4f5e9b81..fc4871a58 100644 --- a/packages/linux-x64-musl-baseline/package.json +++ b/packages/linux-x64-musl-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json index c720cb548..98b6c63a0 100644 --- a/packages/linux-x64-musl/package.json +++ b/packages/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index be62e8c92..155a12524 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64)", "license": "MIT", "repository": { diff --git a/packages/windows-x64-baseline/package.json b/packages/windows-x64-baseline/package.json index f77ff07a5..253fccf2d 100644 --- a/packages/windows-x64-baseline/package.json +++ b/packages/windows-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json index 25e1d7612..a3ef0824a 100644 --- a/packages/windows-x64/package.json +++ b/packages/windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64)", "license": "MIT", "repository": { diff --git a/postinstall.mjs b/postinstall.mjs index 5fe05f702..cdebb7e68 100644 --- a/postinstall.mjs +++ b/postinstall.mjs @@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js"; const require = createRequire(import.meta.url); +const MIN_OPENCODE_VERSION = "1.4.0"; + +/** + * Parse version string into numeric parts + * @param {string} version + * @returns {number[]} + */ +function parseVersion(version) { + return version + .replace(/^v/, "") + .split("-")[0] + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); +} + +/** + * Compare two version strings + * @param {string} current + * @param {string} minimum + * @returns {boolean} true if current >= minimum + */ +function compareVersions(current, minimum) { + const currentParts = parseVersion(current); + const minimumParts = parseVersion(minimum); + const length = Math.max(currentParts.length, minimumParts.length); + + for (let index = 0; index < length; index++) { + const currentPart = currentParts[index] ?? 0; + const minimumPart = minimumParts[index] ?? 0; + if (currentPart > minimumPart) return true; + if (currentPart < minimumPart) return false; + } + + return true; +} + +/** + * Check if opencode version meets minimum requirement + * @returns {{ok: boolean, version: string | null}} + */ +function checkOpenCodeVersion() { + try { + const result = require("child_process").execSync("opencode --version", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }); + const version = result.trim(); + const ok = compareVersions(version, MIN_OPENCODE_VERSION); + return { ok, version }; + } catch { + return { ok: true, version: null }; + } +} + /** * Detect libc family on Linux */ @@ -36,7 +90,15 @@ function main() { const { platform, arch } = process; const libcFamily = getLibcFamily(); const packageBaseName = getPackageBaseName(); - + + // Check opencode version requirement + const versionCheck = checkOpenCodeVersion(); + if (versionCheck.version && !versionCheck.ok) { + console.warn(`⚠ oh-my-opencode requires OpenCode >= ${MIN_OPENCODE_VERSION}`); + console.warn(` Detected: ${versionCheck.version}`); + console.warn(` Please update OpenCode to avoid compatibility issues.`); + } + try { const packageCandidates = getPlatformPackageCandidates({ platform, diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index 18ee99355..2a84ef907 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,14 +1,17 @@ -import { zodToJsonSchema } from "zod-to-json-schema" +import { z } from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" export function createOhMyOpenCodeJsonSchema(): Record { - const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema) as Record + const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { + target: "draft-7", + unrepresentable: "any", + }) as Record return { - ...jsonSchema, $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", + ...jsonSchema, } } diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts index 9604fc217..f1f45eb5b 100644 --- a/script/publish-workflow.test.ts +++ b/script/publish-workflow.test.ts @@ -15,7 +15,7 @@ describe("test workflows", () => { const workflow = readFileSync(workflowPath, "utf8") expect(workflow).toContain("- name: Run tests") - expect(workflow).toContain("run: bun test") + expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/) } }) }) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { diff --git a/script/tsconfig.json b/script/tsconfig.json index 330ebffce..44f60d25b 100644 --- a/script/tsconfig.json +++ b/script/tsconfig.json @@ -7,6 +7,7 @@ "resolveJsonModule": true, "lib": ["ESNext"], "types": ["bun-types"], + "skipLibCheck": true, "allowImportingTsExtensions": true, "noEmit": true }, diff --git a/signatures/cla.json b/signatures/cla.json index b4757b477..eb6fda9cf 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2599,6 +2599,54 @@ "created_at": "2026-04-06T15:37:54Z", "repoId": 1108837393, "pullRequestNo": 3160 + }, + { + "name": "teneburu", + "id": 43727604, + "comment_id": 4199167526, + "created_at": "2026-04-07T13:06:07Z", + "repoId": 1108837393, + "pullRequestNo": 3203 + }, + { + "name": "dhruvkej9", + "id": 96516827, + "comment_id": 4204071246, + "created_at": "2026-04-08T05:36:52Z", + "repoId": 1108837393, + "pullRequestNo": 3217 + }, + { + "name": "dhruvkej9", + "id": 96516827, + "comment_id": 4204084942, + "created_at": "2026-04-08T05:40:40Z", + "repoId": 1108837393, + "pullRequestNo": 3217 + }, + { + "name": "FrancoStino", + "id": 32127923, + "comment_id": 4205715582, + "created_at": "2026-04-08T10:52:39Z", + "repoId": 1108837393, + "pullRequestNo": 3234 + }, + { + "name": "sen7971", + "id": 193416996, + "comment_id": 4207621925, + "created_at": "2026-04-08T15:57:15Z", + "repoId": 1108837393, + "pullRequestNo": 3248 + }, + { + "name": "NikkeTryHard", + "id": 111729769, + "comment_id": 4210843488, + "created_at": "2026-04-09T01:34:03Z", + "repoId": 1108837393, + "pullRequestNo": 3261 } ] } \ No newline at end of file diff --git a/src/agents/agent-identity.test.ts b/src/agents/agent-identity.test.ts new file mode 100644 index 000000000..1247cda17 --- /dev/null +++ b/src/agents/agent-identity.test.ts @@ -0,0 +1,141 @@ +/// + +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("") + expect(result).toContain("") + }) + + 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("") + expect(config.prompt).toContain("Sisyphus") + expect(config.prompt).toContain("") + }) + + 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("") + const roleIndex = prompt.indexOf("") + + 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("") + expect(config.prompt).toContain("Sisyphus") + expect(config.prompt).toContain("") + }) + }) + }) +}) + +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("") + expect(config.prompt).toContain("Hephaestus") + expect(config.prompt).toContain("") + }) + + 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("") + + 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("") + expect(merged.prompt).toContain("Sisyphus") + expect(merged.prompt).toContain("") + 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("") + expect(merged.prompt).toContain("Sisyphus") + expect(merged.prompt).toContain("") + }) + }) + }) +}) diff --git a/src/agents/atlas/agent.ts b/src/agents/atlas/agent.ts index 19dcfbcb9..b348869b6 100644 --- a/src/agents/atlas/agent.ts +++ b/src/agents/atlas/agent.ts @@ -14,7 +14,7 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentMode, AgentPromptMetadata } from "../types" import { isGptModel, isGeminiModel } from "../types" import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder" -import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" +import { buildAgentIdentitySection, buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" import type { CategoryConfig } from "../../config/schema" import { mergeCategories } from "../../shared/merge-categories" @@ -88,9 +88,13 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string { const skillsSection = buildSkillsSection(skills) const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills) + const agentIdentity = buildAgentIdentitySection( + "Atlas", + "Master Orchestrator agent from OhMyOpenCode that coordinates specialized agents to complete todo lists", + ) const basePrompt = getAtlasPrompt(model) - return basePrompt + return agentIdentity + "\n" + basePrompt .replace("{CATEGORY_SECTION}", categorySection) .replace("{AGENT_SECTION}", agentSection) .replace("{DECISION_MATRIX}", decisionMatrix) diff --git a/src/agents/dynamic-agent-core-sections.ts b/src/agents/dynamic-agent-core-sections.ts index e4ec09317..dc91fd480 100644 --- a/src/agents/dynamic-agent-core-sections.ts +++ b/src/agents/dynamic-agent-core-sections.ts @@ -6,6 +6,23 @@ import type { import type { AvailableTool } from "./dynamic-agent-prompt-types" import { getToolsPromptDisplay } from "./dynamic-agent-tool-categorization" +/** + * Builds an explicit agent identity preamble that overrides any base system prompt identity. + * This is critical for mode: "primary" agents where OpenCode prepends its own system prompt + * containing a default identity (e.g., "You are Claude"). Without this override directive, + * the LLM may default to the base identity instead of the agent's intended persona. + */ +export function buildAgentIdentitySection( + agentName: string, + roleDescription: string, +): string { + return ` +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. +` +} + export function buildKeyTriggersSection( agents: AvailableAgent[], _skills: AvailableSkill[] = [], diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index bec7c4427..aa9ee8758 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -8,6 +8,7 @@ export type { export { categorizeTools } from "./dynamic-agent-tool-categorization" export { + buildAgentIdentitySection, buildKeyTriggersSection, buildToolSelectionTable, buildExploreSection, diff --git a/src/agents/hephaestus/agent.ts b/src/agents/hephaestus/agent.ts index fc21027e5..c6ce3bc1b 100644 --- a/src/agents/hephaestus/agent.ts +++ b/src/agents/hephaestus/agent.ts @@ -7,7 +7,7 @@ import type { AvailableSkill, AvailableCategory, } from "../dynamic-agent-prompt-builder"; -import { categorizeTools } from "../dynamic-agent-prompt-builder"; +import { categorizeTools, buildAgentIdentitySection } from "../dynamic-agent-prompt-builder"; import { buildHephaestusPrompt as buildGptPrompt } from "./gpt"; import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex"; @@ -87,7 +87,12 @@ function buildDynamicHephaestusPrompt(ctx?: HephaestusContext): string { break; } - return basePrompt; + const agentIdentity = buildAgentIdentitySection( + "Hephaestus", + "Autonomous deep worker for software engineering from OhMyOpenCode", + ); + + return `${agentIdentity}\n${basePrompt}`; } export function createHephaestusAgent( diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 059f080e2..52442c359 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -26,6 +26,7 @@ import type { AvailableCategory, } from "./dynamic-agent-prompt-builder"; import { + buildAgentIdentitySection, buildKeyTriggersSection, buildToolSelectionTable, buildExploreSection, @@ -72,7 +73,13 @@ function buildDynamicSisyphusPrompt( ? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])" : "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])"; - return ` + const agentIdentity = buildAgentIdentitySection( + "Sisyphus", + "Powerful AI Agent with orchestration capabilities from OhMyOpenCode", + ); + + return `${agentIdentity} + 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. diff --git a/src/agents/sisyphus/gpt-5-4.ts b/src/agents/sisyphus/gpt-5-4.ts index c111aa2e2..72d641b40 100644 --- a/src/agents/sisyphus/gpt-5-4.ts +++ b/src/agents/sisyphus/gpt-5-4.ts @@ -28,6 +28,7 @@ import type { AvailableCategory, } from "../dynamic-agent-prompt-builder"; import { + buildAgentIdentitySection, buildKeyTriggersSection, buildToolSelectionTable, buildExploreSection, @@ -106,6 +107,11 @@ export function buildGpt54SisyphusPrompt( ? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])" : "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])"; + const agentIdentity = buildAgentIdentitySection( + "Sisyphus", + "Powerful AI Agent with orchestration capabilities from OhMyOpenCode", + ); + const identityBlock = ` 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, `; - return `${identityBlock} + return `${agentIdentity} +${identityBlock} ${constraintsBlock} diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 036f8cc55..e6dee4dc8 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -915,6 +915,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on "model": "opencode/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -1132,6 +1140,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is "model": "opencode/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -1353,6 +1369,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -1534,6 +1555,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -1842,6 +1868,17 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -2114,6 +2151,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -2353,6 +2395,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat "model": "anthropic/claude-haiku-4-5", }, "librarian": { + "fallback_models": [ + { + "model": "anthropic/claude-haiku-4-5", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -2573,6 +2620,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -2586,6 +2644,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -2949,6 +3018,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -2966,6 +3049,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "anthropic/claude-haiku-4-5", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -3472,6 +3569,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -3489,6 +3600,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "anthropic/claude-haiku-4-5", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 5d5fd0ca5..934d18322 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -21,11 +21,12 @@ describe("runCliInstaller", () => { console.error = originalConsoleError }) - it("completes installation without auth plugin or provider config steps", async () => { - //#given + it("blocks installation when OpenCode is below the minimum version", async () => { + // given const restoreSpies = [ spyOn(configManager, "detectCurrentConfig").mockReturnValue({ isInstalled: false, + installedVersion: null, hasClaude: false, isMax20: false, hasOpenAI: false, @@ -34,9 +35,56 @@ describe("runCliInstaller", () => { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, }), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), - spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"), + ] + const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig") + + const args: InstallArgs = { + tui: false, + claude: "no", + openai: "no", + gemini: "no", + copilot: "no", + opencodeZen: "no", + zaiCodingPlan: "no", + kimiForCoding: "no", + opencodeGo: "no", + } + + // when + const result = await runCliInstaller(args, "3.16.0") + + // then + expect(result).toBe(1) + expect(addPluginSpy).not.toHaveBeenCalled() + + for (const spy of restoreSpies) { + spy.mockRestore() + } + addPluginSpy.mockRestore() + }) + + it("completes installation without auth plugin or provider config steps", async () => { + // given + const restoreSpies = [ + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ success: true, configPath: "/tmp/opencode.jsonc", @@ -56,12 +104,13 @@ describe("runCliInstaller", () => { opencodeZen: "no", zaiCodingPlan: "no", kimiForCoding: "no", + opencodeGo: "no", } - //#when + // when const result = await runCliInstaller(args, "3.4.0") - //#then + // then expect(result).toBe(0) for (const spy of restoreSpies) { diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 220ba2879..0808488aa 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -22,6 +22,7 @@ import { printWarning, validateNonTuiArgs, } from "./install-validators" +import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" export async function runCliInstaller(args: InstallArgs, version: string): Promise { const validation = validateNonTuiArgs(args) @@ -57,6 +58,12 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi printInfo("Visit https://opencode.ai/docs for installation instructions") } else { printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`) + + const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) + if (unsupportedVersionMessage) { + printWarning(unsupportedVersionMessage) + return 1 + } } if (isUpdate) { diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 73a81ad6a..43cbd6dab 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config" export type { BunInstallResult } from "./config-manager/bun-install" export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install" + +export type { VersionCompatibility } from "./config-manager/version-compatibility" +export { + checkVersionCompatibility, + extractVersionFromPluginEntry, +} from "./config-manager/version-compatibility" + +export type { BackupResult } from "./config-manager/backup-config" +export { backupConfigFile } from "./config-manager/backup-config" diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 19b265ec5..23c398873 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -1,12 +1,14 @@ import { readFileSync, writeFileSync } from "node:fs" import type { ConfigMergeResult } from "../types" import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared" +import { backupConfigFile } from "./backup-config" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" import { formatErrorWithSuggestion } from "./format-error-with-suggestion" import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" import { getPluginNameWithVersion } from "./plugin-name-with-version" +import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility" export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { @@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise && !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)) ) + const existingEntry = canonicalEntries[0] ?? legacyEntries[0] + if (existingEntry) { + const installedVersion = extractVersionFromPluginEntry(existingEntry) + const compatibility = checkVersionCompatibility(installedVersion, currentVersion) + + if (!compatibility.canUpgrade) { + return { + success: false, + configPath: path, + error: compatibility.reason ?? "Version compatibility check failed", + } + } + + const backupResult = backupConfigFile(path) + if (!backupResult.success) { + return { + success: false, + configPath: path, + error: `Failed to create backup: ${backupResult.error}`, + } + } + } + const normalizedPlugins = [...otherPlugins] - if (canonicalEntries.length > 0) { - normalizedPlugins.push(canonicalEntries[0]) - } else if (legacyEntries.length > 0) { - const versionMatch = legacyEntries[0].match(/@(.+)$/) - const preservedVersion = versionMatch ? versionMatch[1] : null - normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry) + if (canonicalEntries.length > 0 || legacyEntries.length > 0) { + normalizedPlugins.push(pluginEntry) } else { normalizedPlugins.push(pluginEntry) } diff --git a/src/cli/config-manager/backup-config.ts b/src/cli/config-manager/backup-config.ts new file mode 100644 index 000000000..682c5dd55 --- /dev/null +++ b/src/cli/config-manager/backup-config.ts @@ -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", + } + } +} diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts index 3679d5bd6..f158e18e2 100644 --- a/src/cli/config-manager/detect-current-config.ts +++ b/src/cli/config-manager/detect-current-config.ts @@ -4,6 +4,7 @@ import type { DetectedConfig } from "../types" import { getOmoConfigPath } from "./config-context" import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file" +import { extractVersionFromPluginEntry } from "./version-compatibility" function detectProvidersFromOmoConfig(): { hasOpenAI: boolean @@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean { plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) } +function findOurPluginEntry(plugins: string[]): string | null { + return plugins.find(isOurPlugin) ?? null +} + export function detectCurrentConfig(): DetectedConfig { const result: DetectedConfig = { isInstalled: false, + installedVersion: null, hasClaude: true, isMax20: true, hasOpenAI: true, @@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig { const openCodeConfig = parseResult.config const plugins = openCodeConfig.plugin ?? [] - result.isInstalled = plugins.some(isOurPlugin) + const ourPluginEntry = findOurPluginEntry(plugins) + result.isInstalled = !!ourPluginEntry + + if (ourPluginEntry) { + result.installedVersion = extractVersionFromPluginEntry(ourPluginEntry) + } if (!result.isInstalled) { return result diff --git a/src/cli/config-manager/plugin-detection.test.ts b/src/cli/config-manager/plugin-detection.test.ts index e03e63357..fcd6109f9 100644 --- a/src/cli/config-manager/plugin-detection.test.ts +++ b/src/cli/config-manager/plugin-detection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -6,6 +6,7 @@ import { join } from "node:path" import { resetConfigContext } from "./config-context" import { detectCurrentConfig } from "./detect-current-config" import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config" +import * as pluginNameWithVersion from "./plugin-name-with-version" describe("detectCurrentConfig - single package detection", () => { let testConfigDir = "" @@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) }) - it("upgrades a version-pinned legacy entry to canonical", async () => { + it("updates a version-pinned legacy entry to the requested version", async () => { // given - writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", "utf-8") + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.16.0") + writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.15.0"] }, null, 2) + "\n", "utf-8") // when - const result = await addPluginToOpenCodeConfig("3.11.0") + const result = await addPluginToOpenCodeConfig("3.16.0") // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"]) + expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"]) + getPluginNameWithVersionSpy.mockRestore() }) it("removes stale legacy entry when canonical and legacy entries both exist", async () => { @@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) }) - it("preserves a canonical entry when it already exists", async () => { + it("preserves a canonical entry when the same version is re-installed", async () => { // given + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.10.0") writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8") // when - const result = await addPluginToOpenCodeConfig("3.11.0") + const result = await addPluginToOpenCodeConfig("3.10.0") // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"]) + getPluginNameWithVersionSpy.mockRestore() + }) + + it("blocks a downgrade for a version-pinned canonical entry", async () => { + // given + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0") + writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.16.0"] }, null, 2) + "\n", "utf-8") + + // when + const result = await addPluginToOpenCodeConfig("3.15.0") + + // then + expect(result.success).toBe(false) + expect(result.error).toContain("Downgrade") + + const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) + expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"]) + getPluginNameWithVersionSpy.mockRestore() }) it("rewrites quoted jsonc plugin field in place", async () => { diff --git a/src/cli/config-manager/version-compatibility.test.ts b/src/cli/config-manager/version-compatibility.test.ts new file mode 100644 index 000000000..95f743452 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.test.ts @@ -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") + }) +}) diff --git a/src/cli/config-manager/version-compatibility.ts b/src/cli/config-manager/version-compatibility.ts new file mode 100644 index 000000000..1042dc1d6 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.ts @@ -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 +} diff --git a/src/cli/config-manager/write-omo-config.test.ts b/src/cli/config-manager/write-omo-config.test.ts index 5701b53dc..48ae5c620 100644 --- a/src/cli/config-manager/write-omo-config.test.ts +++ b/src/cli/config-manager/write-omo-config.test.ts @@ -18,6 +18,7 @@ const installConfig: InstallConfig = { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, } function getRecord(value: unknown): Record { diff --git a/src/cli/config-manager/write-omo-config.ts b/src/cli/config-manager/write-omo-config.ts index 261175e7a..697322584 100644 --- a/src/cli/config-manager/write-omo-config.ts +++ b/src/cli/config-manager/write-omo-config.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs" import { parseJsonc } from "../../shared" import type { ConfigMergeResult, InstallConfig } from "../types" +import { backupConfigFile } from "./backup-config" import { getConfigDir, getOmoConfigPath } from "./config-context" import { deepMergeRecord } from "./deep-merge-record" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" @@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult const newConfig = generateOmoConfig(installConfig) if (existsSync(omoConfigPath)) { + const backupResult = backupConfigFile(omoConfigPath) + if (!backupResult.success) { + return { + success: false, + configPath: omoConfigPath, + error: `Failed to create backup: ${backupResult.error}`, + } + } + try { const stat = statSync(omoConfigPath) const content = readFileSync(omoConfigPath, "utf-8") diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts index 9afaf5a88..39bab0568 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -37,7 +37,7 @@ export const EXIT_CODES = { FAILURE: 1, } as const -export const MIN_OPENCODE_VERSION = "1.0.150" +export const MIN_OPENCODE_VERSION = "1.4.0" export const PACKAGE_NAME = PLUGIN_NAME diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index cf4b7f633..61bcf645f 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -128,7 +128,7 @@ describe("install CLI - binary check behavior", () => { test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => { // given OpenCode binary IS installed isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true) - getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200") + getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") // given mock npm fetch globalThis.fetch = mock(() => @@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => { // then should have printed success (OK symbol) const allCalls = mockConsoleLog.mock.calls.flat().join("\n") expect(allCalls).toContain("[OK]") - expect(allCalls).toContain("OpenCode 1.0.200") + expect(allCalls).toContain("OpenCode 1.4.0") }) }) diff --git a/src/cli/minimum-opencode-version.ts b/src/cli/minimum-opencode-version.ts new file mode 100644 index 000000000..93804568c --- /dev/null +++ b/src/cli/minimum-opencode-version.ts @@ -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.` +} diff --git a/src/cli/model-fallback.test.ts b/src/cli/model-fallback.test.ts index 888f5336b..57ff2c16e 100644 --- a/src/cli/model-fallback.test.ts +++ b/src/cli/model-fallback.test.ts @@ -549,6 +549,58 @@ describe("generateModelConfig", () => { }) }) + describe("special-case agents include fallback_models", () => { + test("explore includes fallback_models when Copilot and Claude are both available", () => { + // #given both Copilot and Claude are available + const config = createConfig({ hasCopilot: true, hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should have fallback_models from the remaining chain entries + expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5") + expect(result.agents?.explore?.fallback_models).toBeDefined() + expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0) + }) + + test("explore omits fallback_models when only one provider matches chain entries", () => { + // #given only Claude is available + const config = createConfig({ hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should not have fallback_models (only one chain entry matches) + expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5") + expect(result.agents?.explore?.fallback_models).toBeUndefined() + }) + + test("librarian includes fallback_models when opencode-go and Claude are both available", () => { + // #given opencode-go and Claude are available + const config = createConfig({ hasOpencodeGo: true, hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should have fallback_models + expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7") + expect(result.agents?.librarian?.fallback_models).toBeDefined() + expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0) + }) + + test("librarian omits fallback_models when only one provider matches", () => { + // #given only opencode-go is available + const config = createConfig({ hasOpencodeGo: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should not have fallback_models + expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7") + expect(result.agents?.librarian?.fallback_models).toBeUndefined() + }) + }) + describe("schema URL", () => { test("always includes correct schema URL", () => { // #given any config diff --git a/src/cli/model-fallback.ts b/src/cli/model-fallback.ts index aa2ef0e74..5dabb9fc1 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -37,22 +37,29 @@ function toFallbackModelObject(entry: FallbackEntry, provider: string): Fallback } } -function attachFallbackModels( - config: T, +function collectAvailableFallbacks( fallbackChain: FallbackEntry[], availability: ReturnType, -): T { +): FallbackModelObject[] { const expandedFallbacks = fallbackChain.flatMap((entry) => entry.providers .filter((provider) => isProviderAvailable(provider, availability)) .map((provider) => toFallbackModelObject(entry, provider)) ) - const uniqueFallbacks = expandedFallbacks.filter((entry, index, allEntries) => + return expandedFallbacks.filter((entry, index, allEntries) => allEntries.findIndex((candidate) => candidate.model === entry.model && candidate.variant === entry.variant ) === index ) +} + +function attachFallbackModels( + config: T, + fallbackChain: FallbackEntry[], + availability: ReturnType, +): T { + const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability) const primaryIndex = uniqueFallbacks.findIndex((entry) => entry.model === config.model) if (primaryIndex === -1) { return config @@ -69,6 +76,23 @@ function attachFallbackModels( } } +function attachAllFallbackModels( + config: T, + fallbackChain: FallbackEntry[], + availability: ReturnType, +): T { + const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability) + const fallbackModels = uniqueFallbacks.filter((entry) => entry.model !== config.model) + if (fallbackModels.length === 0) { + return config + } + + return { + ...config, + fallback_models: fallbackModels, + } +} + export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { @@ -101,26 +125,32 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) { if (role === "librarian") { + let agentConfig: AgentConfig | undefined if (avail.opencodeGo) { - agents[role] = { model: "opencode-go/minimax-m2.7" } + agentConfig = { model: "opencode-go/minimax-m2.7" } } else if (avail.zai) { - agents[role] = { model: ZAI_MODEL } + agentConfig = { model: ZAI_MODEL } + } + if (agentConfig) { + agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail) } continue } if (role === "explore") { + let agentConfig: AgentConfig if (avail.native.claude) { - agents[role] = { model: "anthropic/claude-haiku-4-5" } + agentConfig = { model: "anthropic/claude-haiku-4-5" } } else if (avail.opencodeZen) { - agents[role] = { model: "opencode/claude-haiku-4-5" } + agentConfig = { model: "opencode/claude-haiku-4-5" } } else if (avail.opencodeGo) { - agents[role] = { model: "opencode-go/minimax-m2.7" } + agentConfig = { model: "opencode-go/minimax-m2.7" } } else if (avail.copilot) { - agents[role] = { model: "github-copilot/gpt-5-mini" } + agentConfig = { model: "github-copilot/gpt-5-mini" } } else { - agents[role] = { model: "opencode/gpt-5-nano" } + agentConfig = { model: "opencode/gpt-5-nano" } } + agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail) continue } diff --git a/src/cli/tui-installer.test.ts b/src/cli/tui-installer.test.ts new file mode 100644 index 000000000..dc5ca718f --- /dev/null +++ b/src/cli/tui-installer.test.ts @@ -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 { + 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() + } + }) +}) diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 68f075474..973e387f4 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -10,6 +10,7 @@ import { writeOmoConfig, } from "./config-manager" import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" +import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig } from "./tui-install-prompts" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { @@ -39,6 +40,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") } else { spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`) + + const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) + if (unsupportedVersionMessage) { + p.log.warn(unsupportedVersionMessage) + p.outro(color.red("Installation blocked.")) + return 1 + } } const config = await promptInstallConfig(detected) diff --git a/src/cli/types.ts b/src/cli/types.ts index 7cffad1f2..a8f785cb0 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -34,6 +34,7 @@ export interface ConfigMergeResult { export interface DetectedConfig { isInstalled: boolean + installedVersion: string | null hasClaude: boolean isMax20: boolean hasOpenAI: boolean diff --git a/src/features/background-agent/compaction-aware-message-resolver.test.ts b/src/features/background-agent/compaction-aware-message-resolver.test.ts index 5b9bed5af..d4fe51046 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.test.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { @@ -11,6 +11,7 @@ import { clearCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" +import { PART_STORAGE } from "../../shared" describe("isCompactionAgent", () => { describe("#given agent name variations", () => { @@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => { afterEach(() => { rmSync(tempDir, { force: true, recursive: true }) + rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true }) clearCompactionAgentConfigCheckpoint("ses_checkpoint") }) @@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => { expect(result?.agent).toBe("sisyphus") }) + test("skips JSON messages whose part storage contains a compaction marker", () => { + // given + const compactionMessageID = "msg_test_background_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + writeFileSync(join(tempDir, "002.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + })) + writeFileSync(join(tempDir, "001.json"), JSON.stringify({ + id: "msg_001", + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + })) + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" })) + + // when + const result = findNearestMessageExcludingCompaction(tempDir) + + // then + expect(result?.agent).toBe("sisyphus") + }) + test("falls back to partial agent/model match", () => { // given const messageWithAgentOnly = { @@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => { tools: { bash: true }, }) }) + + test("skips SDK messages that only exist to mark compaction", () => { + // given + const messages = [ + { + id: "msg_compaction", + info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } }, + parts: [{ type: "compaction" }], + }, + { info: { agent: "sisyphus" } }, + { info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } }, + { info: { tools: { bash: true } } }, + ] + + // when + const result = resolvePromptContextFromSessionMessages(messages) + + // then + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-1" }, + tools: { bash: true }, + }) + }) }) diff --git a/src/features/background-agent/compaction-aware-message-resolver.ts b/src/features/background-agent/compaction-aware-message-resolver.ts index 60b3949b3..573002b4f 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.ts @@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs" import { join } from "node:path" import type { StoredMessage } from "../hook-message-injector" import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint" +import { + hasCompactionPartInStorage, + isCompactionAgent, + isCompactionMessage, +} from "../../shared/compaction-marker" + +export { isCompactionAgent } from "../../shared/compaction-marker" type SessionMessage = { + id?: string info?: { agent?: string model?: { @@ -15,10 +23,7 @@ type SessionMessage = { modelID?: string tools?: StoredMessage["tools"] } -} - -export function isCompactionAgent(agent: string | undefined): boolean { - return agent?.trim().toLowerCase() === "compaction" + parts?: Array<{ type?: string }> } function hasFullAgentAndModel(message: StoredMessage): boolean { @@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean { } function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null { + if (isCompactionMessage(message)) { + return null + } + const info = message.info if (!info) { return null @@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction( for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") - messages.push(JSON.parse(content) as StoredMessage) + const parsed = JSON.parse(content) as StoredMessage & { id?: string } + if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) { + continue + } + messages.push(parsed) } catch { continue } diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 4b6675610..5a3a430e6 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map }).rootDescendantCounts } +function getPreStartDescendantReservations(manager: BackgroundManager): Set { + return (manager as unknown as { preStartDescendantReservations: Set }).preStartDescendantReservations +} + function getQueuesByKey( manager: BackgroundManager ): Map> { @@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => { prompt: promptMock, promptAsync: promptMock, abort: async () => ({}), - messages: async () => ({ data: [] }), + messages: async () => ({ + data: [{ + info: { + agent: "explore", + model: { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "high", + }, + }, + }], + }), }, } const manager = new BackgroundManager( @@ -1177,6 +1192,101 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => { }) }) +describe("BackgroundManager.notifyParentSession - variant propagation", () => { + test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => { + //#given + const promptCalls: Array<{ body: Record }> = [] + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + 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 }) + .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 }> = [] + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + 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 }) + .notifyParentSession(task) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].body.variant).toBeUndefined() + + manager.shutdown() + }) +}) + describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => { test("should prepend queued notifications to first text part and clear queue", () => { // given @@ -1431,6 +1541,47 @@ describe("BackgroundManager.tryCompleteTask", () => { expect(task.concurrencyKey).toBeUndefined() }) + test("should mark task as error when startTask throws after session creation", async () => { + //#given - startTask creates session but fails before sending prompt + const concurrencyKey = "anthropic/claude-opus-4-6" + + const task = createMockTask({ + id: "task-zombie-session", + sessionID: "session-zombie-placeholder", + parentSessionID: "parent-zombie", + status: "pending", + agent: "explore", + }) + delete (task as Partial).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 }).startTask = async (item) => { + item.task.status = "running" + item.task.sessionID = "ses_zombie_child" + item.task.startedAt = new Date() + item.task.concurrencyKey = concurrencyKey + throw new Error("crash between session creation and prompt send") + } + + //#when + await processKeyForTest(manager, concurrencyKey) + + //#then - task must be marked as error, not left in running zombie state + expect(task.status).toBe("error") + expect(task.error).toContain("crash between session creation and prompt send") + expect(task.completedAt).toBeDefined() + }) + test("should release queue slot when queued task is already interrupt", async () => { // given const concurrencyKey = "anthropic/claude-opus-4-6" @@ -1739,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => { expect(getSessionPromptParams("session-advanced")).toEqual({ temperature: 0.25, topP: 0.55, + maxOutputTokens: 8192, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 8192, }, }) }) @@ -2339,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(retryTask.status).toBe("pending") }) + test("should only roll back the failed task reservation once when siblings still exist", async () => { + // given + const concurrencyKey = "test-agent" + const task = createMockTask({ + id: "task-single-reservation-rollback", + sessionID: "session-single-reservation-rollback", + parentSessionID: "session-root", + status: "pending", + agent: "test-agent", + rootSessionID: "session-root", + }) + delete (task as Partial).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 + }).startTask = async () => { + throw new Error("session create failed") + } + + // when + await processKeyForTest(manager, concurrencyKey) + + // then + expect(getRootDescendantCounts(manager).get("session-root")).toBe(1) + }) + test("should keep the next queued task when the first task is cancelled during session creation", async () => { // given const firstSessionID = "ses-first-cancelled-during-create" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 2c58dda06..bd8ff2477 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -371,7 +371,7 @@ export class BackgroundManager { this.markPreStartDescendantReservation(task) // Trigger processing (fire-and-forget) - this.processKey(key) + void this.processKey(key) return { ...task } } catch (error) { @@ -408,12 +408,31 @@ export class BackgroundManager { } catch (error) { log("[background-agent] Error starting task:", error) this.rollbackPreStartDescendantReservation(item.task) + + // Mark task as error so the parent polling loop detects the failure + // instead of leaving it in a zombie "running" state with no prompt sent + item.task.status = "error" + item.task.error = error instanceof Error ? error.message : String(error) + item.task.completedAt = new Date() + if (item.task.concurrencyKey) { this.concurrencyManager.release(item.task.concurrencyKey) item.task.concurrencyKey = undefined } else { this.concurrencyManager.release(key) } + + removeTaskToastTracking(item.task.id) + + // Abort the orphaned session if one was created before the error + if (item.task.sessionID) { + await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup") + } + + this.markForNotification(item.task) + this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => { + log("[background-agent] Failed to notify on startTask error:", err) + }) } } } finally { @@ -1760,6 +1779,7 @@ export class BackgroundManager { let agent: string | undefined = task.parentAgent let model: { providerID: string; modelID: string } | undefined let tools: Record | undefined = task.parentTools + let promptContext: ReturnType = null if (this.enableParentSessionNotifications) { try { @@ -1773,7 +1793,7 @@ export class BackgroundManager { tools?: Record } }>) - const promptContext = resolvePromptContextFromSessionMessages( + promptContext = resolvePromptContextFromSessionMessages( messages, task.parentSessionID, ) @@ -1817,6 +1837,8 @@ export class BackgroundManager { const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt" const shouldReply = allComplete || isTaskFailure + const variant = promptContext?.model?.variant + try { await this.client.session.promptAsync({ path: { id: task.parentSessionID }, @@ -1824,6 +1846,7 @@ export class BackgroundManager { noReply: !shouldReply, ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), + ...(variant !== undefined ? { variant } : {}), ...(resolvedTools ? { tools: resolvedTools } : {}), parts: [createInternalAgentTextPart(notification)], }, diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index d3896e55f..6abf62bda 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => { expect(getSessionPromptParams("session-123")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) @@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => { }) expect(promptCalls[0]?.body?.variant).toBe("medium") }) + + test("strips leading zwsp from prompt body agent before promptAsync", async () => { + //#given + const promptCalls: Array<{ body?: { agent?: string } }> = [] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: "ses_child_clean_agent" } }), + promptAsync: async (args?: { body?: { agent?: string } }) => { + promptCalls.push(args ?? {}) + return {} + }, + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "\u200Bsisyphus-junior", + parentSessionID: "ses_parent", + parentMessageID: "msg_parent", + }) + + const item = { + task, + input: { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + model: task.model, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as any, ctx as any) + await new Promise((resolve) => setTimeout(resolve, 0)) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior") + }) }) diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 3c2fd7e73..b549c706b 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" import { isInsideTmux } from "../../shared/tmux" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" import type { ConcurrencyManager } from "./concurrency" export const FALLBACK_AGENT = "general" @@ -168,11 +169,12 @@ export async function startTask( } : undefined const launchVariant = input.model?.variant + const normalizedAgent = stripAgentListSortPrefix(input.agent) applySessionPromptParams(sessionID, input.model) const promptBody = { - agent: input.agent, + agent: normalizedAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), system: input.skillContent, @@ -180,7 +182,7 @@ export async function startTask( task: false, call_omo_agent: true, question: false, - ...getAgentToolRestrictions(input.agent), + ...getAgentToolRestrictions(normalizedAgent), }, parts: [createInternalAgentTextPart(input.prompt)], } diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 4bced7abc..4326b42e0 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -433,63 +433,89 @@ describe("boulder-state", () => { }) describe("getPlanProgress", () => { - test("should count completed and uncompleted checkboxes", () => { - // given - plan file with checkboxes + test("should count only top-level tasks under TODOs and Final Verification Wave sections", () => { + // given - plan with top-level tasks in tracked sections const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, `# Plan -- [ ] Task 1 -- [x] Task 2 -- [ ] Task 3 -- [X] Task 4 + +## TODOs +- [ ] 1. Task 1 +- [x] 2. Task 2 +- [ ] 3. Task 3 +- [X] 4. Task 4 + +## Final Verification Wave +- [ ] F1. Final review `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(4) + expect(progress.total).toBe(5) expect(progress.completed).toBe(2) expect(progress.isComplete).toBe(false) }) - test("should count space-indented unchecked checkbox", () => { - // given - plan file with a two-space indented checkbox - const planPath = join(TEST_DIR, "space-indented-plan.md") + test("should ignore nested Acceptance Criteria checkboxes under TODOs (issue #3066)", () => { + // given - plan with 9 completed top-level tasks and unchecked nested acceptance criteria + const planPath = join(TEST_DIR, "issue-3066-plan.md") writeFileSync(planPath, `# Plan - - [ ] indented task + +## TODOs +- [x] 1. Implement feature A + + **Acceptance Criteria** + - [ ] criterion 1 + - [ ] criterion 2 + +- [x] 2. Implement feature B + + **Acceptance Criteria** + - [ ] criterion 3 + - [ ] criterion 4 + +- [x] 3. Implement feature C +- [x] 4. Implement feature D +- [x] 5. Implement feature E +- [x] 6. Implement feature F +- [x] 7. Implement feature G +- [x] 8. Implement feature H +- [x] 9. Implement feature I + +## Final Verification Wave +- [ ] F1. Final review `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(1) - expect(progress.completed).toBe(0) + expect(progress.total).toBe(10) + expect(progress.completed).toBe(9) expect(progress.isComplete).toBe(false) }) - test("should count tab-indented unchecked checkbox", () => { - // given - plan file with a tab-indented checkbox - const planPath = join(TEST_DIR, "tab-indented-plan.md") + test("should ignore checkboxes outside TODOs and Final Verification Wave sections", () => { + // given - plan with checkboxes in Work Objectives, Success Criteria, and other sections + const planPath = join(TEST_DIR, "ignore-other-sections-plan.md") writeFileSync(planPath, `# Plan - - [ ] tab-indented task -`) - // when - const progress = getPlanProgress(planPath) +## Work Objectives - // then - expect(progress.total).toBe(1) - expect(progress.completed).toBe(0) - expect(progress.isComplete).toBe(false) - }) +### Definition of Done +- [ ] Verifiable condition with command - test("should count mixed top-level checked and indented unchecked checkboxes", () => { - // given - plan file with checked top-level and unchecked indented task - const planPath = join(TEST_DIR, "mixed-indented-plan.md") - writeFileSync(planPath, `# Plan -- [x] top-level completed task - - [ ] nested unchecked task +## TODOs +- [x] 1. Real task one +- [ ] 2. Real task two + +## Success Criteria + +### Final Checklist +- [ ] All Must Have present +- [ ] All Must NOT Have absent +- [ ] All tests pass `) // when @@ -501,11 +527,14 @@ describe("boulder-state", () => { expect(progress.isComplete).toBe(false) }) - test("should count space-indented completed checkbox", () => { - // given - plan file with a two-space indented completed checkbox - const planPath = join(TEST_DIR, "indented-completed-plan.md") + test("should ignore indented checkboxes under top-level tasks", () => { + // given - plan with indented unchecked nested checkboxes + const planPath = join(TEST_DIR, "nested-indented-plan.md") writeFileSync(planPath, `# Plan - - [x] indented completed task + +## TODOs +- [x] 1. top-level completed task + - [ ] nested unchecked task `) // when @@ -517,20 +546,67 @@ describe("boulder-state", () => { expect(progress.isComplete).toBe(true) }) - test("should return isComplete true when all checked", () => { - // given - all tasks completed - const planPath = join(TEST_DIR, "complete-plan.md") + test("should require proper task label format in TODOs", () => { + // given - plan with malformed labels (no numeric prefix) + const planPath = join(TEST_DIR, "malformed-labels-plan.md") writeFileSync(planPath, `# Plan -- [x] Task 1 -- [X] Task 2 + +## TODOs +- [ ] no number prefix +- [x] 1. Valid numbered task `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(2) + expect(progress.total).toBe(1) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(true) + }) + + test("should require F-prefix label format in Final Verification Wave", () => { + // given - plan with malformed final-wave labels + const planPath = join(TEST_DIR, "malformed-final-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Implementation done + +## Final Verification Wave +- [ ] missing F-prefix +- [ ] F1. Proper final review +- [x] F2. Another final review +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(3) expect(progress.completed).toBe(2) + expect(progress.isComplete).toBe(false) + }) + + test("should return isComplete true when all top-level tasks checked", () => { + // given - all top-level tasks completed + const planPath = join(TEST_DIR, "complete-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Task 1 +- [X] 2. Task 2 + +## Final Verification Wave +- [x] F1. Final review +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(3) + expect(progress.completed).toBe(3) expect(progress.isComplete).toBe(true) }) @@ -555,6 +631,84 @@ describe("boulder-state", () => { expect(progress.total).toBe(0) expect(progress.isComplete).toBe(true) }) + + test("should support asterisk bullet top-level tasks", () => { + // given - plan with asterisk bullet tasks + const planPath = join(TEST_DIR, "asterisk-bullet-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +* [x] 1. Task using asterisk bullet +* [ ] 2. Another asterisk task +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) + + test("should count only top-level checkboxes for simple plans with nested tasks", () => { + // given + const planPath = join(TEST_DIR, "simple-nested-plan.md") + writeFileSync(planPath, `# Plan + +- [ ] Top-level task 1 + - [x] Nested task ignored +- [x] Top-level task 2 + * [ ] Another nested task ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) + + test("should treat final-wave-only plans as structured mode", () => { + // given + const planPath = join(TEST_DIR, "final-wave-only-plan.md") + writeFileSync(planPath, `# Plan + +## Final Verification Wave +- [ ] F1. Top-level final review + - [x] Nested verification detail ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(1) + expect(progress.completed).toBe(0) + expect(progress.isComplete).toBe(false) + }) + + test("should ignore mixed indentation levels in simple plans", () => { + // given + const planPath = join(TEST_DIR, "simple-mixed-indentation-plan.md") + writeFileSync(planPath, `# Plan + +* [x] Top-level star task + - [ ] Indented task ignored + - [x] Tab-indented task ignored +- [ ] Top-level dash task +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) }) describe("getPlanName", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index 0b3eefe54..d570ce525 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -196,8 +196,25 @@ export function findPrometheusPlans(directory: string): string[] { } } +const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i +const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i +const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ +const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ +const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ +const TODO_TASK_PATTERN = /^\d+\.\s+/ +const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i + +type ProgressSection = "todo" | "final-wave" | "other" + /** * Parse a plan file and count checkbox progress. + * + * Only top-level (zero-indent) checkboxes under `## TODOs` and + * `## Final Verification Wave` sections are counted. The checkbox + * body must carry a valid task label (`N.` for TODOs, `FN.` for + * Final Verification Wave). Nested acceptance-criteria checkboxes + * and checkboxes in other sections are intentionally ignored so + * that progress tracking stays aligned with `readCurrentTopLevelTask`. */ export function getPlanProgress(planPath: string): PlanProgress { if (!existsSync(planPath)) { @@ -206,24 +223,89 @@ export function getPlanProgress(planPath: string): PlanProgress { try { const content = readFileSync(planPath, "utf-8") - - // Match markdown checkboxes: - [ ] or - [x] or - [X] - const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] - const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] + const lines = content.split(/\r?\n/) - const total = uncheckedMatches.length + checkedMatches.length - const completed = checkedMatches.length + // Check if the plan has structured sections (## TODOs / ## Final Verification Wave) + const hasStructuredSections = lines.some( + (line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line), + ) - return { - total, - completed, - isComplete: total > 0 && completed === total, + if (hasStructuredSections) { + // Structured plan: only count top-level checkboxes with numbered labels + // under ## TODOs and ## Final Verification Wave sections + return getStructuredPlanProgress(lines) } + + // Simple plan: count all top-level checkboxes anywhere + return getSimplePlanProgress(content) } catch { return { total: 0, completed: 0, isComplete: true } } } +function getStructuredPlanProgress(lines: string[]): PlanProgress { + let section: ProgressSection = "other" + let total = 0 + let completed = 0 + + for (const line of lines) { + if (SECOND_LEVEL_HEADING_PATTERN.test(line)) { + section = TODO_HEADING_PATTERN.test(line) + ? "todo" + : FINAL_VERIFICATION_HEADING_PATTERN.test(line) + ? "final-wave" + : "other" + continue + } + + if (section !== "todo" && section !== "final-wave") { + continue + } + + const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN) + const uncheckedMatch = checkedMatch ? null : line.match(UNCHECKED_CHECKBOX_PATTERN) + const match = checkedMatch ?? uncheckedMatch + if (!match) { + continue + } + + if (match[1].length > 0) { + continue + } + + const taskBody = match[2].trim() + const labelPattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN + if (!labelPattern.test(taskBody)) { + continue + } + + total++ + if (checkedMatch) { + completed++ + } + } + + return { + total, + completed, + isComplete: total > 0 && completed === total, + } +} + +function getSimplePlanProgress(content: string): PlanProgress { + const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] + const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] + + const total = uncheckedMatches.length + checkedMatches.length + const completed = checkedMatches.length + + return { + total, + completed, + isComplete: total > 0 && completed === total, + } +} + /** * Extract plan name from file path. */ diff --git a/src/features/boulder-state/top-level-task.ts b/src/features/boulder-state/top-level-task.ts index d92970b56..feff10f0c 100644 --- a/src/features/boulder-state/top-level-task.ts +++ b/src/features/boulder-state/top-level-task.ts @@ -6,6 +6,7 @@ const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ +const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/ const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i diff --git a/src/features/builtin-commands/templates/ralph-loop.test.ts b/src/features/builtin-commands/templates/ralph-loop.test.ts new file mode 100644 index 000000000..ae8440ae1 --- /dev/null +++ b/src/features/builtin-commands/templates/ralph-loop.test.ts @@ -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) + }) +}) diff --git a/src/features/builtin-commands/templates/ralph-loop.ts b/src/features/builtin-commands/templates/ralph-loop.ts index 5da026a70..1fb8bae50 100644 --- a/src/features/builtin-commands/templates/ralph-loop.ts +++ b/src/features/builtin-commands/templates/ralph-loop.ts @@ -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: \`{{COMPLETION_PROMISE}}\` 3. That does NOT finish the loop yet. The system will require Oracle verification 4. The loop only ends after the system confirms Oracle verified the result -5. There is no iteration limit +5. The iteration limit is 500 for ultrawork mode, 100 for normal mode ## Rules diff --git a/src/features/claude-code-mcp-loader/env-expander.test.ts b/src/features/claude-code-mcp-loader/env-expander.test.ts index ae93e01dc..1aa9c4419 100644 --- a/src/features/claude-code-mcp-loader/env-expander.test.ts +++ b/src/features/claude-code-mcp-loader/env-expander.test.ts @@ -120,6 +120,32 @@ describe("expandEnvVars", () => { expect(expanded).toBe("user-approved") }) }) + + describe("#given a sensitive environment variable expanded in trusted mode", () => { + it("#when expanding the value #then it returns the env value bypassing the allowlist", () => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-trusted" + + // when + const expanded = expandEnvVars("${SLACK_USER_TOKEN}", { trusted: true }) + + // then + expect(expanded).toBe("xoxp-trusted") + }) + }) + + describe("#given an unset env var expanded in trusted mode with a default", () => { + it("#when expanding the value #then it returns the default value", () => { + // given + delete process.env.UNSET_TRUSTED_VAR + + // when + const expanded = expandEnvVars("${UNSET_TRUSTED_VAR:-fallback}", { trusted: true }) + + // then + expect(expanded).toBe("fallback") + }) + }) }) describe("expandEnvVarsInObject", () => { @@ -165,4 +191,69 @@ describe("expandEnvVarsInObject", () => { }) }) }) + + describe("#given a trusted skill MCP config object with sensitive env vars", () => { + it("#when expanding env vars in trusted mode #then it expands all referenced env vars", () => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-trusted-token" + process.env.HOME = "/Users/tester" + + // when + const expanded = expandEnvVarsInObject( + { + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer ${SLACK_USER_TOKEN}", + ], + env: { + HOME_DIR: "${HOME}", + }, + }, + { trusted: true } + ) + + // then + expect(expanded).toEqual({ + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer xoxp-trusted-token", + ], + env: { + HOME_DIR: "/Users/tester", + }, + }) + }) + + it("#when expanding a remote http skill MCP config in trusted mode #then it expands sensitive headers", () => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-trusted-token" + + // when + const expanded = expandEnvVarsInObject( + { + url: "https://mcp.slack.com/mcp", + headers: { + Authorization: "Bearer ${SLACK_USER_TOKEN}", + }, + }, + { trusted: true } + ) + + // then + expect(expanded).toEqual({ + url: "https://mcp.slack.com/mcp", + headers: { + Authorization: "Bearer xoxp-trusted-token", + }, + }) + }) + }) }) diff --git a/src/features/claude-code-mcp-loader/env-expander.ts b/src/features/claude-code-mcp-loader/env-expander.ts index 254d7a6a2..ad3264f96 100644 --- a/src/features/claude-code-mcp-loader/env-expander.ts +++ b/src/features/claude-code-mcp-loader/env-expander.ts @@ -4,11 +4,16 @@ import { isSensitiveMcpEnvVar, } from "./configure-allowed-env-vars" -export function expandEnvVars(value: string): string { +export interface ExpandEnvVarsOptions { + trusted?: boolean +} + +export function expandEnvVars(value: string, options: ExpandEnvVarsOptions = {}): string { + const { trusted = false } = options return value.replace( /\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, varName: string, defaultValue?: string) => { - if (!isAllowedMcpEnvVar(varName)) { + if (!trusted && !isAllowedMcpEnvVar(varName)) { const isSensitive = isSensitiveMcpEnvVar(varName) const reason = isSensitive ? "sensitive variable" : "not in allowlist" @@ -29,16 +34,16 @@ export function expandEnvVars(value: string): string { ) } -export function expandEnvVarsInObject(obj: T): T { +export function expandEnvVarsInObject(obj: T, options: ExpandEnvVarsOptions = {}): T { if (obj === null || obj === undefined) return obj - if (typeof obj === "string") return expandEnvVars(obj) as T + if (typeof obj === "string") return expandEnvVars(obj, options) as T if (Array.isArray(obj)) { - return obj.map((item) => expandEnvVarsInObject(item)) as T + return obj.map((item) => expandEnvVarsInObject(item, options)) as T } if (typeof obj === "object") { const result: Record = {} for (const [key, value] of Object.entries(obj)) { - result[key] = expandEnvVarsInObject(value) + result[key] = expandEnvVarsInObject(value, options) } return result as T } diff --git a/src/features/claude-code-plugin-loader/discovery.test.ts b/src/features/claude-code-plugin-loader/discovery.test.ts index 6e3e1cd34..2d4930ac0 100644 --- a/src/features/claude-code-plugin-loader/discovery.test.ts +++ b/src/features/claude-code-plugin-loader/discovery.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path" const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME const temporaryDirectories: string[] = [] +const originalCwd = process.cwd() function createTemporaryDirectory(prefix: string): string { const directory = mkdtempSync(join(tmpdir(), prefix)) @@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string { return directory } +function writeDatabase(pluginsHome: string, database: unknown): void { + writeFileSync(join(pluginsHome, "installed_plugins.json"), JSON.stringify(database), "utf-8") +} + +function createInstallPath(prefix: string): string { + return createTemporaryDirectory(prefix) +} + describe("discoverInstalledPlugins", () => { beforeEach(() => { mock.module("../../shared/logger", () => ({ @@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => { process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome } + if (process.cwd() !== originalCwd) { + process.chdir(originalCwd) + } + for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }) } @@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => { expect(discovered.plugins).toHaveLength(1) expect(discovered.plugins[0]?.name).toBe("oh-my-openagent") }) + + describe("#given project-scoped entries in v1 format", () => { + it("#when cwd matches projectPath #then the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-match-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "project-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("project-plugin") + }) + + it("#when cwd is a subdirectory of projectPath #then the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-sub-") + const subdirectory = join(projectDirectory, "packages", "app") + mkdirSync(subdirectory, { recursive: true }) + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "sub-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(subdirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-sub`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("sub-plugin") + }) + + it("#when cwd does not match projectPath #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-miss-") + const otherDirectory = createTemporaryDirectory("omo-v1-other-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "outside-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when projectPath is missing #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "no-path-plugin@market": { + scope: "project", + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-noproj`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when scope is user #then it always loads regardless of cwd", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const unrelatedDirectory = createTemporaryDirectory("omo-v1-unrelated-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "user-plugin@market": { + scope: "user", + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(unrelatedDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-user`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("user-plugin") + }) + }) + + describe("#given project and local scoped entries in v2 format", () => { + it("#when cwd matches project-scoped projectPath #then it loads while non-matching entries are dropped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-project-") + const otherDirectory = createTemporaryDirectory("omo-v2-other-") + const matchingInstall = createInstallPath("omo-v2-match-install-") + const missingInstall = createInstallPath("omo-v2-miss-install-") + const userInstall = createInstallPath("omo-v2-user-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "matching-project@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath: matchingInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + "other-project@market": [ + { + scope: "project", + projectPath: otherDirectory, + installPath: missingInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + "global-user@market": [ + { + scope: "user", + installPath: userInstall, + version: "2.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-mix`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + const names = discovered.plugins.map((plugin) => plugin.name).sort() + expect(names).toEqual(["global-user", "matching-project"]) + }) + + it("#when scope is local and cwd matches projectPath #then it loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-local-match-") + const installPath = createInstallPath("omo-v2-local-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "local-plugin@market": [ + { + scope: "local", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("local-plugin") + }) + + it("#when scope is local and cwd does not match projectPath #then it is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-local-miss-") + const otherDirectory = createTemporaryDirectory("omo-v2-local-other-") + const installPath = createInstallPath("omo-v2-local-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "local-plugin@market": [ + { + scope: "local", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when multiple installations are present #then only the first is considered and scope filtering still applies", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-multi-") + const otherDirectory = createTemporaryDirectory("omo-v2-multi-other-") + const primaryInstall = createInstallPath("omo-v2-multi-primary-") + const secondaryInstall = createInstallPath("omo-v2-multi-secondary-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "multi-plugin@market": [ + { + scope: "project", + projectPath: otherDirectory, + installPath: primaryInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + { + scope: "project", + projectPath: projectDirectory, + installPath: secondaryInstall, + version: "2.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-multi`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then — existing behavior keeps only the first entry; with scope filter it is + // (correctly) skipped because the first entry points at a different project. + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + }) + + describe("#given project and local scoped entries in v3 flat-array format", () => { + it("#when cwd matches projectPath #then projectPath flows through and the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v3-match-") + const installPath = createInstallPath("omo-v3-install-") + writeDatabase(pluginsHome, [ + { + name: "v3-project-plugin", + marketplace: "market", + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ]) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("v3-project-plugin") + }) + + it("#when cwd does not match projectPath #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v3-miss-") + const otherDirectory = createTemporaryDirectory("omo-v3-miss-other-") + const installPath = createInstallPath("omo-v3-install-") + writeDatabase(pluginsHome, [ + { + name: "v3-skipped-plugin", + marketplace: "market", + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + { + name: "v3-user-plugin", + marketplace: "market", + scope: "user", + installPath: createInstallPath("omo-v3-user-install-"), + version: "2.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ]) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("v3-user-plugin") + }) + }) + + describe("#given enabledPluginsOverride combined with scope filtering", () => { + it("#when a project-scoped plugin is disabled via override #then it is still skipped even if cwd would match", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-enabled-proj-") + const installPath = createInstallPath("omo-enabled-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "gated-plugin@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-off`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + enabledPluginsOverride: { "gated-plugin@market": false }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when a project-scoped plugin is enabled and cwd matches #then it loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-enabled-match-") + const installPath = createInstallPath("omo-enabled-match-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "enabled-plugin@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-on`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + enabledPluginsOverride: { "enabled-plugin@market": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("enabled-plugin") + }) + }) }) diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts index f4781fef3..4a633782b 100644 --- a/src/features/claude-code-plugin-loader/discovery.ts +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -3,6 +3,7 @@ import { homedir } from "os" import { basename, join } from "path" import { fileURLToPath } from "url" import { log } from "../../shared/logger" +import { shouldLoadPluginForCwd } from "./scope-filter" import type { InstalledPluginsDatabase, InstalledPluginEntryV3, @@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio installedAt: entry.lastUpdated, lastUpdated: entry.lastUpdated, gitCommitSha: entry.gitCommitSha, + projectPath: entry.projectPath, } } @@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL const settingsEnabledPlugins = settings?.enabledPlugins const overrideEnabledPlugins = options?.enabledPluginsOverride const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest + const cwd = process.cwd() for (const [pluginKey, installation] of extractPluginEntries(db)) { if (!installation) continue @@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL continue } + if (!shouldLoadPluginForCwd(installation, cwd)) { + log(`Skipping ${installation.scope}-scoped plugin outside current cwd: ${pluginKey}`, { + projectPath: installation.projectPath, + cwd, + }) + continue + } + const { installPath, scope, version } = installation if (!existsSync(installPath)) { diff --git a/src/features/claude-code-plugin-loader/scope-filter.test.ts b/src/features/claude-code-plugin-loader/scope-filter.test.ts new file mode 100644 index 000000000..3ac585e3d --- /dev/null +++ b/src/features/claude-code-plugin-loader/scope-filter.test.ts @@ -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) + }) + }) +}) diff --git a/src/features/claude-code-plugin-loader/scope-filter.ts b/src/features/claude-code-plugin-loader/scope-filter.ts new file mode 100644 index 000000000..b3651b5c5 --- /dev/null +++ b/src/features/claude-code-plugin-loader/scope-filter.ts @@ -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, + 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) +} diff --git a/src/features/claude-code-plugin-loader/types.ts b/src/features/claude-code-plugin-loader/types.ts index d93d6979b..1db4dd16f 100644 --- a/src/features/claude-code-plugin-loader/types.ts +++ b/src/features/claude-code-plugin-loader/types.ts @@ -18,6 +18,12 @@ export interface PluginInstallation { lastUpdated: string gitCommitSha?: string isLocal?: boolean + /** + * Claude Code records this on project/local-scoped installations. + * Absolute path (or `~`-prefixed) of the project the plugin was installed for. + * Used to filter project/local plugins that do not belong to the current cwd. + */ + projectPath?: string } /** @@ -51,6 +57,11 @@ export interface InstalledPluginEntryV3 { installPath: string lastUpdated: string gitCommitSha?: string + /** + * Claude Code records this on project/local-scoped installations. + * Absolute path (or `~`-prefixed) of the project the plugin was installed for. + */ + projectPath?: string } /** diff --git a/src/features/claude-code-session-state/state.test.ts b/src/features/claude-code-session-state/state.test.ts index 89a755bdb..69c482b40 100644 --- a/src/features/claude-code-session-state/state.test.ts +++ b/src/features/claude-code-session-state/state.test.ts @@ -10,6 +10,7 @@ import { getMainSessionID, registerAgentName, isAgentRegistered, + resolveRegisteredAgentName, _resetForTesting, } from "./state" @@ -28,7 +29,7 @@ describe("claude-code-session-state", () => { test("should store agent for session", () => { // given const sessionID = "test-session-1" - const agent = "Prometheus (Planner)" + const agent = "Prometheus - Plan Builder" // when setSessionAgent(sessionID, agent) @@ -52,13 +53,13 @@ describe("claude-code-session-state", () => { test("should NOT overwrite existing agent (first-write wins)", () => { // given const sessionID = "test-session-1" - setSessionAgent(sessionID, "Prometheus (Planner)") + setSessionAgent(sessionID, "Prometheus - Plan Builder") // when - try to overwrite setSessionAgent(sessionID, "sisyphus") // then - first agent preserved - expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)") + expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder") }) test("should return undefined for unknown session", () => { @@ -73,7 +74,7 @@ describe("claude-code-session-state", () => { test("should overwrite existing agent", () => { // given const sessionID = "test-session-1" - setSessionAgent(sessionID, "Prometheus (Planner)") + setSessionAgent(sessionID, "Prometheus - Plan Builder") // when - force update updateSessionAgent(sessionID, "sisyphus") @@ -99,8 +100,8 @@ describe("claude-code-session-state", () => { test("should remove agent from session", () => { // given const sessionID = "test-session-1" - setSessionAgent(sessionID, "Prometheus (Planner)") - expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)") + setSessionAgent(sessionID, "Prometheus - Plan Builder") + expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder") // when clearSessionAgent(sessionID) @@ -140,6 +141,15 @@ describe("claude-code-session-state", () => { expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true) }) + test("should resolve config keys back to the registered raw agent name", () => { + // given + registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + + // when / then + expect(resolveRegisteredAgentName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + expect(resolveRegisteredAgentName("Atlas - Plan Executor")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + }) + describe("#given atlas display name with zero-width prefix", () => { describe("#when checking registration without the zero-width prefix", () => { test("#then it treats the display name as registered", () => { @@ -160,15 +170,15 @@ describe("claude-code-session-state", () => { test("should correctly identify Prometheus agent for permission checks", () => { // given - Prometheus session const sessionID = "test-prometheus-session" - const prometheusAgent = "Prometheus (Planner)" + const prometheusAgent = "Prometheus - Plan Builder" // when - agent is set (simulating chat.message hook) setSessionAgent(sessionID, prometheusAgent) // then - getSessionAgent returns correct agent for prometheus-md-only hook const agent = getSessionAgent(sessionID) - expect(agent).toBe("Prometheus (Planner)") - expect(["Prometheus (Planner)"].includes(agent!)).toBe(true) + expect(agent).toBe("Prometheus - Plan Builder") + expect(["Prometheus - Plan Builder"].includes(agent!)).toBe(true) }) test("should return undefined when agent not set (bug scenario)", () => { diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts index f044b4ec6..496d655fd 100644 --- a/src/features/claude-code-session-state/state.ts +++ b/src/features/claude-code-session-state/state.ts @@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined { } const registeredAgentNames = new Set() +const registeredAgentAliases = new Map() const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g @@ -28,10 +29,16 @@ function normalizeStoredAgentName(name: string): string { export function registerAgentName(name: string): void { const normalizedName = normalizeRegisteredAgentName(name) registeredAgentNames.add(normalizedName) + if (!registeredAgentAliases.has(normalizedName)) { + registeredAgentAliases.set(normalizedName, name) + } const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name)) if (configKey !== normalizedName) { registeredAgentNames.add(configKey) + if (!registeredAgentAliases.has(configKey)) { + registeredAgentAliases.set(configKey, name) + } } } @@ -39,6 +46,15 @@ export function isAgentRegistered(name: string): boolean { return registeredAgentNames.has(normalizeRegisteredAgentName(name)) } +export function resolveRegisteredAgentName(name: string | undefined): string | undefined { + if (typeof name !== "string") { + return undefined + } + + const normalizedName = normalizeRegisteredAgentName(name) + return registeredAgentAliases.get(normalizedName) ?? normalizeStoredAgentName(name) +} + /** @internal For testing only */ export function _resetForTesting(): void { _mainSessionID = undefined @@ -46,6 +62,7 @@ export function _resetForTesting(): void { syncSubagentSessions.clear() sessionAgentMap.clear() registeredAgentNames.clear() + registeredAgentAliases.clear() } const sessionAgentMap = new Map() diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index 663b5e068..3db367640 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -11,6 +11,7 @@ import { generatePartId, injectHookMessage, } from "./injector" +import { PART_STORAGE } from "../../shared" import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" //#region Mocks @@ -53,6 +54,7 @@ function createMockClient(messages: Array<{ tools?: Record time?: { created?: number } } + parts?: Array<{ type?: string }> }>): { session: { messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }> @@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => { expect(result?.agent).toBe("newest-by-time") }) + + it("skips compaction marker user messages when resolving nearest message", async () => { + const mockClient = createMockClient([ + { + id: "msg_compaction", + info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 200 } }, + parts: [{ type: "compaction" }], + }, + { + id: "msg_real", + info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" }, time: { created: 100 } }, + }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("sisyphus") + }) }) describe("findNearestMessageWithFields JSON backend ordering", () => { @@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => { expect(result?.agent).toBe("newest-by-time") }) + + it("skips JSON messages whose parts contain a compaction marker", () => { + mockIsSqliteBackend.mockReturnValue(false) + const messageDir = createMessageDir() + const compactionMessageID = "msg_test_injector_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + tempDirs.push(partDir) + + writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + time: { created: 200 }, + })) + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" })) + + writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({ + id: "msg_0002", + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + time: { created: 100 }, + })) + + const result = findNearestMessageWithFields(messageDir) + + expect(result?.agent).toBe("sisyphus") + }) }) describe("findFirstMessageWithAgentFromSDK", () => { @@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => { expect(result).toBe("earliest-agent") }) + it("skips compaction marker user messages when resolving first agent", async () => { + const mockClient = createMockClient([ + { id: "msg_compaction", info: { agent: "atlas", time: { created: 10 } }, parts: [{ type: "compaction" }] }, + { id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("sisyphus") + }) + it("skips messages without agent field", async () => { const mockClient = createMockClient([ { info: {} }, diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index a0568371e..84ecddf0e 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" +import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker" export interface StoredMessage { agent?: string @@ -32,6 +33,7 @@ interface SDKMessage { created?: number } } + parts?: Array<{ type?: string }> } const processPrefix = randomBytes(4).toString("hex") @@ -39,6 +41,10 @@ let messageCounter = 0 let partCounter = 0 function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { + if (isCompactionMessage(msg)) { + return null + } + const info = msg.info if (!info) return null @@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage return { fileName, msg, + hasCompactionMarker: hasCompactionPartInStorage( + typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined, + ), createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY, } } catch { return null } }) - .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .filter((entry): entry is { + fileName: string + msg: StoredMessage & { time?: { created?: number } } + hasCompactionMarker: boolean + createdAt: number + } => entry !== null) .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) { return entry.msg } } for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) { return entry.msg } @@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { return { fileName, msg, + hasCompactionMarker: hasCompactionPartInStorage( + typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined, + ), createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY, } } catch { return null } }) - .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .filter((entry): entry is { + fileName: string + msg: StoredMessage & { time?: { created?: number } } + hasCompactionMarker: boolean + createdAt: number + } => entry !== null) .sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName)) for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent) { return entry.msg.agent } diff --git a/src/features/mcp-oauth/refresh-mutex.ts b/src/features/mcp-oauth/refresh-mutex.ts new file mode 100644 index 000000000..3b7c3e710 --- /dev/null +++ b/src/features/mcp-oauth/refresh-mutex.ts @@ -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>() + +/** + * 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, +): Promise { + 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 +} diff --git a/src/features/mcp-oauth/storage.ts b/src/features/mcp-oauth/storage.ts index d041bdfd1..2c705f5b7 100644 --- a/src/features/mcp-oauth/storage.ts +++ b/src/features/mcp-oauth/storage.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { getOpenCodeConfigDir } from "../../shared" @@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean { mkdirSync(dir, { recursive: true }) } - writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }) - chmodSync(filePath, 0o600) + const tempPath = `${filePath}.tmp.${Date.now()}` + writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }) + chmodSync(tempPath, 0o600) + renameSync(tempPath, filePath) return true } catch { return false diff --git a/src/features/skill-mcp-manager/connection-env-vars.test.ts b/src/features/skill-mcp-manager/connection-env-vars.test.ts new file mode 100644 index 000000000..60cf20ce6 --- /dev/null +++ b/src/features/skill-mcp-manager/connection-env-vars.test.ts @@ -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 } + ) {} + + async connect(_transport: unknown): Promise { + // Successful connect, env-related assertions happen on transport constructor args + } +} + +class MockStdioClientTransport { + readonly close = mock(async () => {}) + readonly options: { command: string; args?: string[]; env?: Record; stderr?: string } + + constructor(options: { command: string; args?: string[]; env?: Record; stderr?: string }) { + this.options = options + createdStdioTransports.push(this) + } +} + +interface MockHttpTransportOptions { + requestInit?: { headers?: Record } +} + +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, 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" + ) + }) + }) +}) diff --git a/src/features/skill-mcp-manager/connection-race.test.ts b/src/features/skill-mcp-manager/connection-race.test.ts index 3fa00b4c3..652987f67 100644 --- a/src/features/skill-mcp-manager/connection-race.test.ts +++ b/src/features/skill-mcp-manager/connection-race.test.ts @@ -95,6 +95,7 @@ function createClientInfo(sessionID: string): SkillMcpClientInfo { serverName: "race-server", skillName: "race-skill", sessionID, + scope: "builtin", } } diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts index 890444bce..2fa4dc3a3 100644 --- a/src/features/skill-mcp-manager/connection.ts +++ b/src/features/skill-mcp-manager/connection.ts @@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c } } +const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"]) + export async function getOrCreateClient(params: { state: SkillMcpManagerState clientKey: string @@ -38,7 +40,8 @@ export async function getOrCreateClient(params: { return pending } - const expandedConfig = expandEnvVarsInObject(config) + const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "") + const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) let currentConnectionPromise!: Promise state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) currentConnectionPromise = (async () => { diff --git a/src/features/skill-mcp-manager/error-redaction.ts b/src/features/skill-mcp-manager/error-redaction.ts new file mode 100644 index 000000000..d3a3cb0df --- /dev/null +++ b/src/features/skill-mcp-manager/error-redaction.ts @@ -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 +} diff --git a/src/features/skill-mcp-manager/manager-oauth-retry.test.ts b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts new file mode 100644 index 000000000..f887e80e7 --- /dev/null +++ b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 66c36b3ba..bdbc316a1 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -65,6 +65,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -80,6 +81,7 @@ describe("SkillMcpManager", () => { serverName: "my-mcp", skillName: "data-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -95,6 +97,7 @@ describe("SkillMcpManager", () => { serverName: "custom-server", skillName: "custom-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -112,6 +115,7 @@ describe("SkillMcpManager", () => { serverName: "http-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "http", @@ -130,6 +134,7 @@ describe("SkillMcpManager", () => { serverName: "sse-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "sse", @@ -148,6 +153,7 @@ describe("SkillMcpManager", () => { serverName: "inferred-http", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -165,6 +171,7 @@ describe("SkillMcpManager", () => { serverName: "stdio-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -184,6 +191,7 @@ describe("SkillMcpManager", () => { serverName: "inferred-stdio", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "node", @@ -202,6 +210,7 @@ describe("SkillMcpManager", () => { serverName: "mixed-config", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -224,6 +233,7 @@ describe("SkillMcpManager", () => { serverName: "bad-url-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "http", @@ -242,6 +252,7 @@ describe("SkillMcpManager", () => { serverName: "http-error-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://nonexistent.example.com/mcp", @@ -259,6 +270,7 @@ describe("SkillMcpManager", () => { serverName: "hint-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://nonexistent.example.com/mcp", @@ -276,6 +288,7 @@ describe("SkillMcpManager", () => { serverName: "mock-test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -302,6 +315,7 @@ describe("SkillMcpManager", () => { serverName: "missing-command", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -320,6 +334,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "nonexistent-command-xyz", @@ -338,6 +353,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "nonexistent-command", @@ -358,11 +374,13 @@ describe("SkillMcpManager", () => { serverName: "server1", skillName: "skill1", sessionID: "session-1", + scope: "builtin", } const session2Info: SkillMcpClientInfo = { serverName: "server1", skillName: "skill1", sessionID: "session-2", + scope: "builtin", } // when @@ -396,6 +414,7 @@ describe("SkillMcpManager", () => { serverName: "signal-server", skillName: "signal-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -423,11 +442,12 @@ describe("SkillMcpManager", () => { describe("isConnected", () => { it("returns false for unconnected server", () => { // given - const info: SkillMcpClientInfo = { - serverName: "unknown", - skillName: "test", - sessionID: "session-1", - } + const info: SkillMcpClientInfo = { + serverName: "$1", + skillName: "$2", + sessionID: "$3", + scope: "builtin", + } // when / #then expect(manager.isConnected(info)).toBe(false) @@ -448,6 +468,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const configWithoutEnv: ClaudeCodeMcpServer = { command: "node", @@ -471,6 +492,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-2", + scope: "builtin", } const configWithEnv: ClaudeCodeMcpServer = { command: "node", @@ -498,6 +520,7 @@ describe("SkillMcpManager", () => { serverName: "auth-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -526,6 +549,7 @@ describe("SkillMcpManager", () => { serverName: "no-auth-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -546,6 +570,7 @@ describe("SkillMcpManager", () => { serverName: "retry-server", skillName: "retry-skill", sessionID: "session-retry-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -584,6 +609,7 @@ describe("SkillMcpManager", () => { serverName: "fail-server", skillName: "fail-skill", sessionID: "session-fail-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -615,6 +641,7 @@ describe("SkillMcpManager", () => { serverName: "error-server", skillName: "error-skill", sessionID: "session-error-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -653,6 +680,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-server", skillName: "oauth-skill", sessionID: "session-oauth-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -679,6 +707,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-no-token", skillName: "oauth-skill", sessionID: "session-oauth-2", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -705,6 +734,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-with-headers", skillName: "oauth-skill", sessionID: "session-oauth-3", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -734,6 +764,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-refresh", skillName: "oauth-skill", sessionID: "session-oauth-refresh", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -766,6 +797,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-refresh-fallback", skillName: "oauth-skill", sessionID: "session-oauth-refresh-fallback", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -799,6 +831,7 @@ describe("SkillMcpManager", () => { serverName: "no-oauth-server", skillName: "test-skill", sessionID: "session-no-oauth", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -824,6 +857,7 @@ describe("SkillMcpManager", () => { serverName: "stepup-server", skillName: "stepup-skill", sessionID: "session-stepup-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -869,6 +903,7 @@ describe("SkillMcpManager", () => { serverName: "no-stepup-server", skillName: "no-stepup-skill", sessionID: "session-no-stepup", + scope: "builtin", } const context: SkillMcpServerContext = { config: { diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts index 473d5f390..f91524be4 100644 --- a/src/features/skill-mcp-manager/manager.ts +++ b/src/features/skill-mcp-manager/manager.ts @@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { McpOAuthProvider } from "../mcp-oauth/provider" import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup" import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection" -import { handleStepUpIfNeeded } from "./oauth-handler" +import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler" import type { OAuthProviderFactory, SkillMcpClientInfo, @@ -110,6 +110,7 @@ export class SkillMcpManager { ): Promise { const maxRetries = 3 let lastError: Error | null = null + const refreshAttempted = new Set() for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -130,6 +131,17 @@ export class SkillMcpManager { continue } + const postRequestRefreshHandled = await handlePostRequestAuthError({ + error: lastError, + config, + authProviders: this.state.authProviders, + createOAuthProvider: this.state.createOAuthProvider, + refreshAttempted, + }) + if (postRequestRefreshHandled) { + continue + } + if (!errorMessage.includes("not connected")) { throw lastError } diff --git a/src/features/skill-mcp-manager/oauth-handler.test.ts b/src/features/skill-mcp-manager/oauth-handler.test.ts new file mode 100644 index 000000000..35823c6ae --- /dev/null +++ b/src/features/skill-mcp-manager/oauth-handler.test.ts @@ -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 { + 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 = { + promise: Promise + resolve: (value: TValue) => void +} + +function createDeferred(): Deferred { + let resolvePromise: ((value: TValue) => void) | null = null + const promise = new Promise((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() + 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() + 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() + const secondDeferred = createDeferred() + 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([["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) + }) +}) diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts index 5e76a2f81..63f3d8676 100644 --- a/src/features/skill-mcp-manager/oauth-handler.ts +++ b/src/features/skill-mcp-manager/oauth-handler.ts @@ -1,5 +1,6 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { McpOAuthProvider } from "../mcp-oauth/provider" +import { withRefreshMutex } from "../mcp-oauth/refresh-mutex" import type { OAuthTokenData } from "../mcp-oauth/storage" import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up" import type { OAuthProviderFactory, OAuthProviderLike } from "./types" @@ -52,14 +53,15 @@ export async function buildHttpRequestInit( } } - if (tokenData && isTokenExpired(tokenData)) { - try { - tokenData = tokenData.refreshToken - ? await provider.refresh(tokenData.refreshToken) - : await provider.login() - } catch { + if (tokenData && isTokenExpired(tokenData)) { try { - tokenData = await provider.login() + const refreshToken = tokenData.refreshToken + tokenData = refreshToken + ? await withRefreshMutex(config.url, () => provider.refresh(refreshToken)) + : await provider.login() + } catch { + try { + tokenData = await provider.login() } catch { tokenData = null } @@ -116,3 +118,43 @@ export async function handleStepUpIfNeeded(params: { return false } } + +export async function handlePostRequestAuthError(params: { + error: Error + config: ClaudeCodeMcpServer + authProviders: Map + createOAuthProvider?: OAuthProviderFactory + refreshAttempted?: Set +}): Promise { + 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 + } +} diff --git a/src/features/skill-mcp-manager/stdio-client.ts b/src/features/skill-mcp-manager/stdio-client.ts index 0d3e9047c..3a5c796a4 100644 --- a/src/features/skill-mcp-manager/stdio-client.ts +++ b/src/features/skill-mcp-manager/stdio-client.ts @@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { createCleanMcpEnvironment } from "./env-cleaner" import { registerProcessCleanup, startCleanupTimer } from "./cleanup" +import { redactSensitiveData } from "./error-redaction" import type { ManagedClient, SkillMcpClientConnectionParams } from "./types" function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string { @@ -45,10 +46,13 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams): } const errorMessage = error instanceof Error ? error.message : String(error) + const fullCommand = `${command} ${args.join(" ")}` + const safeCommand = redactSensitiveData(fullCommand) + const safeErrorMessage = redactSensitiveData(errorMessage) throw new Error( `Failed to connect to MCP server "${info.serverName}".\n\n` + - `Command: ${command} ${args.join(" ")}\n` + - `Reason: ${errorMessage}\n\n` + + `Command: ${safeCommand}\n` + + `Reason: ${safeErrorMessage}\n\n` + `Hints:\n` + ` - Ensure the command is installed and available in PATH\n` + ` - Check if the MCP server package exists\n` + diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index d2e77e3ae..75ef396cf 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -3,6 +3,7 @@ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdi import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { McpOAuthProvider } from "../mcp-oauth/provider" +import type { SkillScope } from "../opencode-skill-loader/types" export type SkillMcpConfig = Record @@ -10,6 +11,7 @@ export interface SkillMcpClientInfo { serverName: string skillName: string sessionID: string + scope?: SkillScope | "local" } export interface SkillMcpServerContext { diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) diff --git a/src/hooks/atlas/boulder-continuation-injector.test.ts b/src/hooks/atlas/boulder-continuation-injector.test.ts index a0686126d..c72fdb782 100644 --- a/src/hooks/atlas/boulder-continuation-injector.test.ts +++ b/src/hooks/atlas/boulder-continuation-injector.test.ts @@ -121,4 +121,64 @@ describe("injectBoulderContinuation", () => { expect(result).toBe("skipped_agent_unavailable") expect(promptAsyncMock).not.toHaveBeenCalled() }) + + test("#given recent prompt context includes variant #when injecting boulder continuation #then promptAsync receives variant as a top-level field", async () => { + // given + registerAgentName("atlas") + const capturedRequests: Array<{ + body?: { + model?: { providerID: string; modelID: string } + variant?: string + } + }> = [] + const promptAsyncMock = mock(async (request: unknown) => { + capturedRequests.push(request as typeof capturedRequests[number]) + return undefined + }) + const recentModel = { + providerID: "anthropic", + modelID: "claude-sonnet-4-20250514", + variant: "max", + } + const messagesMock = mock(async () => ({ + data: [{ + id: "msg_1", + info: { + agent: "atlas", + model: recentModel, + time: { created: Date.now() }, + }, + }], + })) + + const ctx = { + directory: "/tmp", + client: { + session: { + messages: messagesMock, + promptAsync: promptAsyncMock, + }, + }, + } as unknown as PluginInput + + // when + const result = await injectBoulderContinuation({ + ctx, + sessionID: "ses_test_variant", + planName: "test-plan", + remaining: 1, + total: 2, + agent: "atlas", + sessionState: { promptFailureCount: 0 }, + }) + + // then + expect(result).toBe("injected") + expect(capturedRequests).toHaveLength(1) + expect(capturedRequests[0]?.body?.model).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet-4-20250514", + }) + expect(capturedRequests[0]?.body?.variant).toBe("max") + }) }) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index ecb5c5663..8f3e1a57d 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,6 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { isAgentRegistered } from "../../features/claude-code-session-state" +import { + isAgentRegistered, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { HOOK_NAME } from "./hook-name" @@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: { `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + preferredSessionContext + worktreeContext - const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined) + const continuationAgent = resolveRegisteredAgentName( + agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), + ) if (!continuationAgent || !isAgentRegistered(continuationAgent)) { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { @@ -71,12 +76,18 @@ export async function injectBoulderContinuation(input: { const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID) const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools) - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: continuationAgent, - ...(promptContext.model !== undefined ? { model: promptContext.model } : {}), - ...(inheritedTools ? { tools: inheritedTools } : {}), + const launchModel = promptContext.model + ? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID } + : undefined + const launchVariant = promptContext.model?.variant + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: continuationAgent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + ...(inheritedTools ? { tools: inheritedTools } : {}), parts: [createInternalAgentTextPart(prompt)], }, query: { directory: ctx.directory }, diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts index d7d8b9d9d..e3acf1699 100644 --- a/src/hooks/atlas/recent-model-resolver.ts +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -40,7 +40,14 @@ export async function resolveRecentPromptContextForSession( const model = info?.model const tools = normalizePromptTools(info?.tools) if (model?.providerID && model?.modelID) { - return { model: { providerID: model.providerID, modelID: model.modelID }, tools } + return { + model: { + providerID: model.providerID, + modelID: model.modelID, + ...(model.variant ? { variant: model.variant } : {}), + }, + tools, + } } if (info?.providerID && info?.modelID) { @@ -63,7 +70,14 @@ export async function resolveRecentPromptContextForSession( if (!model?.providerID || !model?.modelID) { return { tools } } - return { model: { providerID: model.providerID, modelID: model.modelID }, tools } + return { + model: { + providerID: model.providerID, + modelID: model.modelID, + ...(model.variant ? { variant: model.variant } : {}), + }, + tools, + } } export async function resolveRecentModelForSession( diff --git a/src/hooks/atlas/session-last-agent.json.test.ts b/src/hooks/atlas/session-last-agent.json.test.ts index 196078a50..fec271338 100644 --- a/src/hooks/atlas/session-last-agent.json.test.ts +++ b/src/hooks/atlas/session-last-agent.json.test.ts @@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" +import { PART_STORAGE } from "../../shared" const testDirs: string[] = [] const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`) @@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => { // then expect(result).toBe("atlas") }) + + test("skips JSON messages whose part storage contains a compaction marker", async () => { + // given + const sessionID = "ses_json_compaction_marker" + const messageDir = createTempMessageDir(sessionID) + const compactionMessageID = "msg_test_atlas_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + testDirs.push(partDir) + writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + time: { created: 200 }, + }), "utf-8") + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ + type: "compaction", + }), "utf-8") + + writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({ + id: "msg_0002", + agent: "sisyphus-junior", + time: { created: 100 }, + }), "utf-8") + + const { getLastAgentFromSession } = await import("./session-last-agent") + + // when + const result = await getLastAgentFromSession(sessionID) + + // then + expect(result).toBe("sisyphus-junior") + }) }) diff --git a/src/hooks/atlas/session-last-agent.sqlite.test.ts b/src/hooks/atlas/session-last-agent.sqlite.test.ts index a5ce6dbcb..5ae770298 100644 --- a/src/hooks/atlas/session-last-agent.sqlite.test.ts +++ b/src/hooks/atlas/session-last-agent.sqlite.test.ts @@ -52,6 +52,30 @@ describe("getLastAgentFromSession SQLite backend ordering", () => { expect(result).toBe("sisyphus-junior") }) + test("skips compaction marker user messages that retain the original agent", async () => { + // given + const client = { + session: { + messages: async () => ({ + data: [ + { id: "msg_real", info: { agent: "sisyphus", time: { created: 100 } } }, + { + id: "msg_compaction", + info: { agent: "atlas", time: { created: 200 } }, + parts: [{ type: "compaction" }], + }, + ], + }), + }, + } + + // when + const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never) + + // then + expect(result).toBe("sisyphus") + }) + test("returns null instead of throwing when SQLite message lookup fails", async () => { // given const client = { diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts index 43933b33f..4f12fb022 100644 --- a/src/hooks/atlas/session-last-agent.ts +++ b/src/hooks/atlas/session-last-agent.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs" import { join } from "node:path" import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared" +import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker" type SessionMessagesClient = { session: { @@ -9,10 +10,6 @@ type SessionMessagesClient = { } } -function isCompactionAgent(agent: unknown): boolean { - return typeof agent === "string" && agent.toLowerCase() === "compaction" -} - function getLastAgentFromMessageDir(messageDir: string): string | null { try { const messages = readdirSync(messageDir) @@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null { .map((fileName) => { try { const content = readFileSync(join(messageDir, fileName), "utf-8") - const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } } + const parsed = JSON.parse(content) as { id?: string; agent?: unknown; time?: { created?: unknown } } return { fileName, + id: parsed.id, agent: parsed.agent, createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY, } @@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null { return null } }) - .filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null) - .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) + .filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null) + .sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? "")) for (const message of messages) { - if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) { + if (!message) continue + if (isCompactionMessage({ agent: message.agent }) || hasCompactionPartInStorage(message?.id)) { + continue + } + + if (typeof message.agent === "string") { return message.agent.toLowerCase() } } @@ -52,7 +55,11 @@ export async function getLastAgentFromSession( if (isSqliteBackend() && client) { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, { + const messages = normalizeSDKResponse(response, [] as Array<{ + id?: string + info?: { agent?: string; time?: { created?: number } } + parts?: Array<{ type?: string }> + }>, { preferResponseOnMissingData: true, }).sort((left, right) => { const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY @@ -67,8 +74,12 @@ export async function getLastAgentFromSession( }) for (const message of messages) { + if (isCompactionMessage(message)) { + continue + } + const agent = message.info?.agent - if (typeof agent === "string" && !isCompactionAgent(agent)) { + if (typeof agent === "string") { return agent.toLowerCase() } } diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 68401a5bd..534478da2 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -2,7 +2,7 @@ import type { AgentOverrides } from "../../config" import type { BackgroundManager } from "../../features/background-agent" import type { TopLevelTaskRef } from "../../features/boulder-state" -export type ModelInfo = { providerID: string; modelID: string } +export type ModelInfo = { providerID: string; modelID: string; variant?: string } export interface AtlasHookOptions { directory: string diff --git a/src/hooks/auto-update-checker/checker/cached-version.test.ts b/src/hooks/auto-update-checker/checker/cached-version.test.ts new file mode 100644 index 000000000..6a6790134 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/cached-version.test.ts @@ -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)[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() + }) +}) diff --git a/src/hooks/auto-update-checker/checker/cached-version.ts b/src/hooks/auto-update-checker/checker/cached-version.ts index 15aef4eff..0041122c3 100644 --- a/src/hooks/auto-update-checker/checker/cached-version.ts +++ b/src/hooks/auto-update-checker/checker/cached-version.ts @@ -3,18 +3,20 @@ import * as path from "node:path" import { fileURLToPath } from "node:url" import { log } from "../../../shared/logger" import type { PackageJson } from "../types" -import { INSTALLED_PACKAGE_JSON } from "../constants" +import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants" import { findPackageJsonUp } from "./package-json-locator" export function getCachedVersion(): string | null { - try { - if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { - const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version + for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) { + try { + if (fs.existsSync(candidate)) { + const content = fs.readFileSync(candidate, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch { + // ignore; try next candidate } - } catch { - // ignore } try { diff --git a/src/hooks/auto-update-checker/checker/local-dev-path.ts b/src/hooks/auto-update-checker/checker/local-dev-path.ts index 5bf1e5ced..e9c820617 100644 --- a/src/hooks/auto-update-checker/checker/local-dev-path.ts +++ b/src/hooks/auto-update-checker/checker/local-dev-path.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs" import { fileURLToPath } from "node:url" import type { OpencodeConfig } from "../types" -import { PACKAGE_NAME } from "../constants" +import { ACCEPTED_PACKAGE_NAMES } from "../constants" import { getConfigPaths } from "./config-paths" import { stripJsonComments } from "./jsonc-strip" @@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null { const plugins = config.plugin ?? [] for (const entry of plugins) { - if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { - try { - return fileURLToPath(entry) - } catch { - return entry.replace("file://", "") - } + if (!entry.startsWith("file://")) continue + if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue + try { + return fileURLToPath(entry) + } catch { + return entry.replace("file://", "") } } } catch { diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.test.ts b/src/hooks/auto-update-checker/checker/package-json-locator.test.ts new file mode 100644 index 000000000..da04eeebd --- /dev/null +++ b/src/hooks/auto-update-checker/checker/package-json-locator.test.ts @@ -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() + }) +}) diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.ts b/src/hooks/auto-update-checker/checker/package-json-locator.ts index 308cad163..9887ef1c8 100644 --- a/src/hooks/auto-update-checker/checker/package-json-locator.ts +++ b/src/hooks/auto-update-checker/checker/package-json-locator.ts @@ -1,7 +1,9 @@ import * as fs from "node:fs" import * as path from "node:path" import type { PackageJson } from "../types" -import { PACKAGE_NAME } from "../constants" +import { ACCEPTED_PACKAGE_NAMES } from "../constants" + +const ACCEPTED_NAME_SET = new Set(ACCEPTED_PACKAGE_NAMES) export function findPackageJsonUp(startPath: string): string | null { try { @@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null { try { const content = fs.readFileSync(pkgPath, "utf-8") const pkg = JSON.parse(content) as PackageJson - if (pkg.name === PACKAGE_NAME) return pkgPath + if (pkg.name && ACCEPTED_NAME_SET.has(pkg.name)) return pkgPath } catch { // ignore } diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts index b3aaf87c4..341839af0 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -3,8 +3,8 @@ import { spawnSync } from "node:child_process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" - -const PACKAGE_NAME = "oh-my-openagent" +import { PACKAGE_NAME } from "../constants" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" type PluginEntryResult = { entry: string @@ -121,6 +121,64 @@ describe("findPluginEntry", () => { expect(pluginInfo?.pinnedVersion).toBe("3.5.2") }) + test("finds preferred plugin entry", async () => { + // #given preferred plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds legacy plugin entry", async () => { + // #given legacy plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [LEGACY_PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then legacy entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(LEGACY_PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds preferred plugin entry with pinned version", async () => { + // #given preferred plugin entry includes semver version + fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PLUGIN_NAME}@3.15.0`] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred versioned entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(`${PLUGIN_NAME}@3.15.0`) + expect(pluginInfo?.isPinned).toBe(true) + expect(pluginInfo?.pinnedVersion).toBe("3.15.0") + }) + + test("returns null for unrelated plugin entry", async () => { + // #given unrelated plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: ["some-other-plugin"] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then no matching entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo).toBeNull() + }) + test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => { // #given profile-specific user config after module import const profileConfigDir = path.join(temporaryDirectory, "profiles", "today") diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts index f204d61f1..55260c94e 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types" import { PACKAGE_NAME } from "../constants" import { getConfigPaths } from "./config-paths" import { stripJsonComments } from "./jsonc-strip" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" export interface PluginEntryInfo { entry: string @@ -12,6 +13,7 @@ export interface PluginEntryInfo { } const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/ +const MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME] export function findPluginEntry(directory: string): PluginEntryInfo | null { for (const configPath of getConfigPaths(directory)) { @@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null { const plugins = config.plugin ?? [] for (const entry of plugins) { - if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null, configPath } - } - if (entry.startsWith(`${PACKAGE_NAME}@`)) { - const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) - const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) - return { entry, isPinned, pinnedVersion, configPath } + for (const pluginName of MATCH_PLUGIN_NAMES) { + if (entry === pluginName) { + return { entry, isPinned: false, pinnedVersion: null, configPath } + } + if (entry.startsWith(`${pluginName}@`)) { + const pinnedVersion = entry.slice(pluginName.length + 1) + const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) + return { entry, isPinned, pinnedVersion, configPath } + } } } } catch { diff --git a/src/hooks/auto-update-checker/constants.test.ts b/src/hooks/auto-update-checker/constants.test.ts index 31bd2efc1..cc0ea44c8 100644 --- a/src/hooks/auto-update-checker/constants.test.ts +++ b/src/hooks/auto-update-checker/constants.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" import { join } from "node:path" +import { fileURLToPath } from "node:url" import { getOpenCodeCacheDir } from "../../shared/data-path" describe("auto-update-checker constants", () => { @@ -11,4 +13,37 @@ describe("auto-update-checker constants", () => { join(getOpenCodeCacheDir(), "packages", "node_modules", PACKAGE_NAME, "package.json") ) }) + + it("PACKAGE_NAME matches the published package.json name", async () => { + // given the canonical package.json shipped with the plugin + const here = fileURLToPath(import.meta.url) + const repoPackageJsonPath = join(here, "..", "..", "..", "..", "package.json") + const repoPackageJson = JSON.parse(readFileSync(repoPackageJsonPath, "utf-8")) as { name: string } + + // when the auto-update-checker constants are loaded + const { PACKAGE_NAME } = await import(`./constants?test=${Date.now()}`) + + // then PACKAGE_NAME equals the actually published package name + expect(PACKAGE_NAME).toBe(repoPackageJson.name) + }) + + it("ACCEPTED_PACKAGE_NAMES contains both the canonical and aliased npm names (GH-3257)", async () => { + const { ACCEPTED_PACKAGE_NAMES } = await import(`./constants?test=${Date.now()}`) + + expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-opencode") + expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-openagent") + }) + + it("INSTALLED_PACKAGE_JSON_CANDIDATES covers every accepted package name (GH-3257)", async () => { + const { ACCEPTED_PACKAGE_NAMES, INSTALLED_PACKAGE_JSON_CANDIDATES, CACHE_DIR } = await import( + `./constants?test=${Date.now()}` + ) + + expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toHaveLength(ACCEPTED_PACKAGE_NAMES.length) + for (const name of ACCEPTED_PACKAGE_NAMES) { + expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toContain( + join(CACHE_DIR, "node_modules", name, "package.json") + ) + } + }) }) diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts index 54eb2c7f6..9de9fb6a0 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -3,7 +3,17 @@ import * as os from "node:os" import { getOpenCodeCacheDir } from "../../shared/data-path" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" -export const PACKAGE_NAME = "oh-my-openagent" +export const PACKAGE_NAME = "oh-my-opencode" +/** + * All package names the canonical plugin may be published under. + * + * The package is published to npm as both `oh-my-opencode` (legacy canonical) + * and `oh-my-openagent` (current canonical). Any code that *reads* an + * installed package.json or walks up from an import path must accept both, + * because the installed name depends on which package the user added to + * their config. Code that *writes* continues to use {@link PACKAGE_NAME}. + */ +export const ACCEPTED_PACKAGE_NAMES = ["oh-my-opencode", "oh-my-openagent"] as const export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags` export const NPM_FETCH_TIMEOUT = 5000 @@ -34,3 +44,11 @@ export const INSTALLED_PACKAGE_JSON = path.join( PACKAGE_NAME, "package.json" ) + +/** + * Candidate paths where the installed package.json may live, in priority order. + * Readers should try each path in order and stop on the first success. + */ +export const INSTALLED_PACKAGE_JSON_CANDIDATES = ACCEPTED_PACKAGE_NAMES.map( + name => path.join(CACHE_DIR, "node_modules", name, "package.json") +) diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 35b8a89de..31040d35f 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -1,4 +1,7 @@ -import { updateSessionAgent } from "../../features/claude-code-session-state" +import { + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { getCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" @@ -66,6 +69,7 @@ export function createRecoveryLogic( checkpointWithAgent, currentPromptConfig, ) + const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent) const model = expectedPromptConfig.model const tools = expectedPromptConfig.tools @@ -81,7 +85,7 @@ export function createRecoveryLogic( path: { id: sessionID }, body: { noReply: true, - agent: expectedPromptConfig.agent, + agent: launchAgent ?? expectedPromptConfig.agent, ...(model ? { model } : {}), ...(tools ? { tools } : {}), parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)], diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts new file mode 100644 index 000000000..ce0a6f066 --- /dev/null +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { createKeywordDetectorHook } from "./index" +import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" + +type StartLoopCall = { + sessionID: string + prompt: string + options: Record +} + +type CancelLoopCall = { sessionID: string } + +function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as any +} + +function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) { + return { + startLoop: (sessionID: string, prompt: string, options?: Record): boolean => { + startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) + return true + }, + cancelLoop: (sessionID: string): boolean => { + cancelLoopCalls.push({ sessionID }) + return true + }, + getState: () => null, + event: async () => {}, + } +} + +describe("keyword-detector ralph-loop activation", () => { + beforeEach(() => { + _resetForTesting() + }) + + afterEach(() => { + _resetForTesting() + }) + + test("#given ulw keyword in main session #when chat.message fires #then ralph-loop startLoop is invoked with the user task", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw build a multi-agent backend architecture" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(1) + expect(startLoopCalls[0].sessionID).toBe("main-session") + expect(startLoopCalls[0].prompt).toContain("build a multi-agent backend architecture") + expect(startLoopCalls[0].options.ultrawork).toBe(true) + }) + + test("#given ultrawork keyword in main session #when chat.message fires #then ralph-loop startLoop is invoked with ultrawork enabled", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork ship the dashboard" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(1) + expect(startLoopCalls[0].sessionID).toBe("main-session") + expect(startLoopCalls[0].prompt).toContain("ship the dashboard") + expect(startLoopCalls[0].options.ultrawork).toBe(true) + }) + + test("#given ulw mentioned mid-sentence #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "I think ulw is cool" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("I think ulw is cool") + }) + + test("#given question about ultrawork #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "what is ultrawork?" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("what is ultrawork?") + }) + + test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "just a normal message" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword with planner agent #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw plan this feature" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "prometheus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword with non-OMO agent #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw build feature" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "OpenCode-Builder" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword without ralphLoop dependency #when chat.message fires #then no error is thrown and prompt is still injected", async () => { + // given + setMainSession("main-session") + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw do this" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).toContain("do this") + }) + + test("#given partial 'ulw' substring in StatefulWidget #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + _resetForTesting() + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "refactor the StatefulWidget component" }], + } + + // when + await hook["chat.message"]({ sessionID: "any-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword inside system-reminder block #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +The system mentions ulw mode in passing. +`, + }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword #when chat.message fires #then prompt is also injected as before", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw refactor the codebase" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).toContain("refactor the codebase") + expect(startLoopCalls).toHaveLength(1) + }) +}) diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index c03e43cc3..ea6348419 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -12,8 +12,25 @@ import { subagentSessions, } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" +import type { RalphLoopHook } from "../ralph-loop" +import { parseRalphLoopArguments } from "../ralph-loop/command-arguments" -export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) { +const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i +const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i + +function extractUltraworkTask(cleanText: string): string { + return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim() +} + +function hasLeadingUltraworkKeyword(cleanText: string): boolean { + return LEADING_ULTRAWORK_PATTERN.test(cleanText) +} + +export function createKeywordDetectorHook( + ctx: PluginInput, + _collector?: ContextCollector, + ralphLoop?: Pick +) { function getRuntimeVariant(input: { variant?: string }, message: Record): string | undefined { if (typeof message["variant"] === "string") { return message["variant"] @@ -64,6 +81,16 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context } } + if (!hasLeadingUltraworkKeyword(cleanText)) { + const preFilterCount = detectedKeywords.length + detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") + if (preFilterCount > detectedKeywords.length) { + log(`[keyword-detector] Filtered non-leading ultrawork keyword`, { + sessionID: input.sessionID, + }) + } + } + if (detectedKeywords.length === 0) { return } @@ -115,6 +142,17 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context sessionID: input.sessionID, }) ) + + if (ralphLoop) { + const userTask = extractUltraworkTask(cleanText) + const parsedArguments = parseRalphLoopArguments(userTask) + ralphLoop.startLoop(input.sessionID, parsedArguments.prompt, { + ultrawork: true, + maxIterations: parsedArguments.maxIterations, + completionPromise: parsedArguments.completionPromise, + strategy: parsedArguments.strategy, + }) + } } const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index c16cbdbb5..637503356 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -332,6 +332,25 @@ describe("model fallback hook", () => { clearPendingModelFallback(sessionID) }) + test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => { + //#given + const sessionID = "ses_model_fallback_explicit_none" + clearPendingModelFallback(sessionID) + setSessionFallbackChain(sessionID, undefined) + + //#when + const set = setPendingModelFallback( + sessionID, + "Sisyphus - Junior", + "anthropic", + "claude-sonnet-4-6", + ) + + //#then + expect(set).toBe(false) + clearPendingModelFallback(sessionID) + }) + test("shows toast when fallback is applied", async () => { //#given const toastCalls: Array<{ title: string; message: string }> = [] diff --git a/src/hooks/model-fallback/hook.ts b/src/hooks/model-fallback/hook.ts index 54d7c9342..b188bd48d 100644 --- a/src/hooks/model-fallback/hook.ts +++ b/src/hooks/model-fallback/hook.ts @@ -42,8 +42,12 @@ const sessionFallbackChains = new Map() export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void { if (!sessionID) return - if (!fallbackChain || fallbackChain.length === 0) { - sessionFallbackChains.delete(sessionID) + if (!fallbackChain) { + sessionFallbackChains.set(sessionID, []) + return + } + if (fallbackChain.length === 0) { + sessionFallbackChains.set(sessionID, []) return } sessionFallbackChains.set(sessionID, fallbackChain) @@ -65,8 +69,9 @@ export function setPendingModelFallback( ): boolean { const agentKey = getAgentConfigKey(agentName) const requirements = AGENT_MODEL_REQUIREMENTS[agentKey] + const hasSessionFallback = sessionFallbackChains.has(sessionID) const sessionFallback = sessionFallbackChains.get(sessionID) - const fallbackChain = sessionFallback && sessionFallback.length > 0 + const fallbackChain = hasSessionFallback ? sessionFallback : requirements?.fallbackChain diff --git a/src/hooks/no-hephaestus-non-gpt/hook.ts b/src/hooks/no-hephaestus-non-gpt/hook.ts index afce7ba9c..66efed424 100644 --- a/src/hooks/no-hephaestus-non-gpt/hook.ts +++ b/src/hooks/no-hephaestus-non-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Hephaestus is trash without GPT.", "For Claude/Kimi/GLM models, always use Sisyphus.", ].join("\n") -const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") - type NoHephaestusNonGptHookOptions = { allowNonGptModel?: boolean } @@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook( if (allowNonGptModel) { return } - input.agent = "sisyphus" + input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" if (output?.message) { - output.message.agent = "sisyphus" + output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" } updateSessionAgent(input.sessionID, "sisyphus") } diff --git a/src/hooks/no-sisyphus-gpt/hook.ts b/src/hooks/no-sisyphus-gpt/hook.ts index 65ab8d113..fa1b53ebd 100644 --- a/src/hooks/no-sisyphus-gpt/hook.ts +++ b/src/hooks/no-sisyphus-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel, isGpt5_4Model } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Sisyphus with GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).", "For GPT models (other than 5.4), always use Hephaestus.", ].join("\n") -const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") - function showToast(ctx: PluginInput, sessionID: string): void { ctx.client.tui.showToast({ body: { @@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) { if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) { showToast(ctx, input.sessionID) - input.agent = "hephaestus" + input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" if (output?.message) { - output.message.agent = "hephaestus" + output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" } updateSessionAgent(input.sessionID, "hephaestus") } diff --git a/src/hooks/preemptive-compaction-degradation-monitor.ts b/src/hooks/preemptive-compaction-degradation-monitor.ts index 2da8ce27f..6c93a0e4e 100644 --- a/src/hooks/preemptive-compaction-degradation-monitor.ts +++ b/src/hooks/preemptive-compaction-degradation-monitor.ts @@ -85,7 +85,6 @@ export function createPostCompactionDegradationMonitor(args: { postCompactionNoTextStreak.delete(sessionID) postCompactionRecoveryTriggered.delete(sessionID) postCompactionEpoch.delete(sessionID) - postCompactionRecoveryCount.delete(sessionID) } const onSessionCompacted = (sessionID: string): void => { diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index ef6e695b0..09cbf83dc 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -284,10 +284,57 @@ describe("preemptive-compaction", () => { //#then expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", { sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", error: String(summarizeError), }) }) + // #given compaction fails + // #when tool.execute.after completes the catch block + // #then should show a warning toast explaining the failure to the user + it("should show a warning toast when preemptive compaction fails", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_toast_on_failure" + const summarizeError = new Error("upstream rate limited") + ctx.client.session.summarize.mockRejectedValueOnce(summarizeError) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + //#when + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_toast" }, + { title: "", output: "test", metadata: null }, + ) + + //#then + expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1) + const toastCall = ctx.client.tui.showToast.mock.calls[0]?.[0] + expect(toastCall?.body?.title).toBe("Preemptive compaction failed") + expect(toastCall?.body?.variant).toBe("warning") + expect(String(toastCall?.body?.message)).toContain("upstream rate limited") + }) + // #given compaction fails // #when tool.execute.after is called again immediately // #then should NOT retry due to cooldown @@ -475,6 +522,8 @@ describe("preemptive-compaction", () => { expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", { sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", error: expect.stringContaining("Compaction summarize timed out"), }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index ef58b1a95..ecab70676 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -8,7 +8,7 @@ import { import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" -const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 +const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 @@ -134,7 +134,25 @@ export function createPreemptiveCompactionHook( compactedSessions.add(sessionID) } catch (error) { - log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) }) + log("[preemptive-compaction] Compaction failed", { + sessionID, + providerID: cached.providerID, + modelID: cached.modelID, + error: String(error), + }) + ctx.client.tui.showToast({ + body: { + title: "Preemptive compaction failed", + message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`, + variant: "warning", + duration: 10000, + }, + }).catch((toastError: unknown) => { + log("[preemptive-compaction] Failed to show toast", { + sessionID, + toastError: String(toastError), + }) + }) } finally { compactionInProgress.delete(sessionID) } diff --git a/src/hooks/prometheus-md-only/constants.ts b/src/hooks/prometheus-md-only/constants.ts index fe2f5ab20..7613a47a8 100644 --- a/src/hooks/prometheus-md-only/constants.ts +++ b/src/hooks/prometheus-md-only/constants.ts @@ -51,14 +51,14 @@ ${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)} │ │ - Record decisions to .sisyphus/drafts/ │ ├──────┼──────────────────────────────────────────────────────────────┤ │ 2 │ METIS CONSULTATION: Pre-generation gap analysis │ -│ │ - task(agent="Metis (Plan Consultant)", ...) │ +│ │ - task(agent="Metis - Plan Consultant", ...) │ │ │ - Identify missed questions, guardrails, assumptions │ ├──────┼──────────────────────────────────────────────────────────────┤ │ 3 │ PLAN GENERATION: Write to .sisyphus/plans/*.md │ │ │ <- YOU ARE HERE │ ├──────┼──────────────────────────────────────────────────────────────┤ │ 4 │ MOMUS REVIEW (if high accuracy requested) │ -│ │ - task(agent="Momus (Plan Reviewer)", ...) │ +│ │ - task(agent="Momus - Plan Critic", ...) │ │ │ - Loop until OKAY verdict │ ├──────┼──────────────────────────────────────────────────────────────┤ │ 5 │ SUMMARY: Present to user │ diff --git a/src/hooks/prometheus-md-only/index.test.ts b/src/hooks/prometheus-md-only/index.test.ts index f51ac88e7..5d609b1f9 100644 --- a/src/hooks/prometheus-md-only/index.test.ts +++ b/src/hooks/prometheus-md-only/index.test.ts @@ -113,7 +113,7 @@ describe("prometheus-md-only", () => { test("should enforce md-only restriction for Prometheus display name Planner", async () => { //#given - setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)") + setupMessageStorage(TEST_SESSION_ID, "Prometheus - Plan Builder") const hook = createPrometheusMdOnlyHook(createMockPluginInput()) const input = { tool: "Write", diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index b6e8f38ec..65718e67e 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs" import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { isOracleVerified } from "./oracle-verification-detector" import { withTimeout } from "./with-timeout" interface OpenCodeSessionMessage { @@ -17,8 +18,6 @@ interface TranscriptEntry { tool_output?: { output?: string } | string } -const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i - function extractTranscriptEntryText(entry: TranscriptEntry): string { if (typeof entry.content === "string") return entry.content if (typeof entry.tool_output === "string") return entry.tool_output @@ -47,7 +46,7 @@ function shouldInspectSessionMessagePart( return false } - return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(partText) + return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(partText) } function shouldInspectTranscriptEntry( @@ -63,7 +62,7 @@ function shouldInspectTranscriptEntry( return false } - return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(entryText) + return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(entryText) } export function detectCompletionInTranscript( diff --git a/src/hooks/ralph-loop/constants.ts b/src/hooks/ralph-loop/constants.ts index c0a44283a..4d750e98a 100644 --- a/src/hooks/ralph-loop/constants.ts +++ b/src/hooks/ralph-loop/constants.ts @@ -2,5 +2,6 @@ export const HOOK_NAME = "ralph-loop" export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md" export const COMPLETION_TAG_PATTERN = /(.*?)<\/promise>/is export const DEFAULT_MAX_ITERATIONS = 100 +export const ULTRAWORK_MAX_ITERATIONS = 500 export const DEFAULT_COMPLETION_PROMISE = "DONE" export const ULTRAWORK_VERIFICATION_PROMISE = "VERIFIED" diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.test.ts b/src/hooks/ralph-loop/continuation-prompt-injector.test.ts new file mode 100644 index 000000000..95cd07294 --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-injector.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { injectContinuationPrompt } from "./continuation-prompt-injector" + +describe("ralph-loop continuation prompt injector", () => { + test("#given inherited message model includes variant #when injecting continuation prompt #then promptAsync receives variant as a top-level field", async () => { + // given + let promptBody: + | { + model?: { providerID: string; modelID: string } + variant?: string + } + | undefined + const model = { + providerID: "openai", + modelID: "gpt-5.3-codex", + variant: "max", + } + const ctx = { + client: { + session: { + messages: async () => ({ + data: [{ info: { agent: "sisyphus", model } }], + }), + promptAsync: async (input: { + body: { + model?: { providerID: string; modelID: string } + variant?: string + } + }) => { + promptBody = input.body + return {} + }, + }, + }, + } + + // when + await injectContinuationPrompt(ctx as never, { + sessionID: "ses_ralph_variant", + prompt: "continue", + directory: "/tmp/test", + apiTimeoutMs: 50, + }) + + // then + expect(promptBody?.model).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex", + }) + expect(promptBody?.variant).toBe("max") + }) +}) diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index 58f31953b..94df8debf 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -11,7 +11,7 @@ import { type MessageInfo = { agent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } modelID?: string providerID?: string tools?: Record @@ -28,7 +28,7 @@ export async function injectContinuationPrompt( }, ): Promise { let agent: string | undefined - let model: { providerID: string; modelID: string } | undefined + let model: { providerID: string; modelID: string; variant?: string } | undefined let tools: Record | undefined const sourceSessionID = options.inheritFromSessionID ?? options.sessionID @@ -62,6 +62,7 @@ export async function injectContinuationPrompt( ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID, + ...(currentMessage.model.variant ? { variant: currentMessage.model.variant } : {}), } : undefined tools = currentMessage?.tools @@ -69,11 +70,17 @@ export async function injectContinuationPrompt( const inheritedTools = resolveInheritedPromptTools(sourceSessionID, tools) + const launchModel = model + ? { providerID: model.providerID, modelID: model.modelID } + : undefined + const launchVariant = model?.variant + await ctx.client.session.promptAsync({ path: { id: options.sessionID }, body: { ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), parts: [createInternalAgentTextPart(options.prompt)], }, diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 49be08da2..2a455412a 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -3,6 +3,7 @@ import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS, HOOK_NAME, + ULTRAWORK_MAX_ITERATIONS, ULTRAWORK_VERIFICATION_PROMISE, } from "./constants" import { clearState, incrementIteration, readState, writeState } from "./storage" @@ -36,7 +37,7 @@ export function createLoopStateController(options: { active: true, iteration: 1, max_iterations: loopOptions?.ultrawork - ? undefined + ? ULTRAWORK_MAX_ITERATIONS : loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, diff --git a/src/hooks/ralph-loop/oracle-verification-detector.test.ts b/src/hooks/ralph-loop/oracle-verification-detector.test.ts new file mode 100644 index 000000000..8b6ef3685 --- /dev/null +++ b/src/hooks/ralph-loop/oracle-verification-detector.test.ts @@ -0,0 +1,294 @@ +/// +import { describe, expect, test } from "bun:test" +import { + extractOracleSessionID, + isOracleVerified, + parseOracleVerificationEvidence, +} from "./oracle-verification-detector" +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" + +describe("parseOracleVerificationEvidence", () => { + test("#given valid oracle verification text #then should parse all fields", () => { + // #given + const text = `Task completed. + +Agent: oracle + +VERIFIED + + +session_id: ses_oracle_123 +` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBe("ses_oracle_123") + }) + + test("#given text without agent line #then should return undefined", () => { + // #given + const text = `VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text without promise tag #then should return undefined", () => { + // #given + const text = `Agent: oracle` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text with empty agent #then should return undefined", () => { + // #given + const text = `Agent: + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text with empty promise #then should return undefined", () => { + // #given + const text = `Agent: oracle + + ` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text without metadata #then should parse agent and promise only", () => { + // #given + const text = `Agent: oracle + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBeUndefined() + }) + + test("#given text with metadata but no session_id #then should parse agent and promise only", () => { + // #given + const text = `Agent: oracle + +VERIFIED + + +other_field: value +` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBeUndefined() + }) + + test("#given empty text #then should return undefined", () => { + // #given + const text = "" + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given whitespace-only text #then should return undefined", () => { + // #given + const text = " \n\t " + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given agent with different casing #then should preserve original case", () => { + // #given + const text = `Agent: ORACLE + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("ORACLE") + }) +}) + +describe("isOracleVerified", () => { + test("#given valid oracle verification #then should return true", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(true) + }) + + test("#given non-oracle agent #then should return false", () => { + // #given + const text = `Agent: sisyphus + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) + + test("#given wrong promise #then should return false", () => { + // #given + const text = `Agent: oracle + +DONE` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) + + test("#given oracle agent with different casing #then should return true", () => { + // #given + const text = `Agent: ORACLE + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(true) + }) + + test("#given empty text #then should return false", () => { + // #given + const text = "" + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) +}) + +describe("extractOracleSessionID", () => { + test("#given valid oracle verification with session_id #then should return session_id", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_oracle_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBe("ses_oracle_123") + }) + + test("#given valid oracle verification without session_id #then should return undefined", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given non-oracle agent #then should return undefined", () => { + // #given + const text = `Agent: sisyphus + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_sis_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given non-oracle agent with different casing #then should return undefined", () => { + // #given + const text = `Agent: SISYPHUS + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_sis_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given empty text #then should return undefined", () => { + // #given + const text = "" + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) +}) diff --git a/src/hooks/ralph-loop/oracle-verification-detector.ts b/src/hooks/ralph-loop/oracle-verification-detector.ts new file mode 100644 index 000000000..304a38809 --- /dev/null +++ b/src/hooks/ralph-loop/oracle-verification-detector.ts @@ -0,0 +1,70 @@ +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" + +export interface OracleVerificationEvidence { + agent: string + promise: string + sessionID?: string +} + +const AGENT_LINE_PATTERN = /^Agent:[ \t]*(\S+)$/im +const PROMISE_TAG_PATTERN = /[ \t]*(\S+?)[ \t]*<\/promise>/is +const TASK_METADATA_PATTERN = /[ \t]*([\s\S]*?)[ \t]*<\/task_metadata>/is +const SESSION_ID_LINE_PATTERN = /^session_id:[ \t]*(\S+)$/im + +export function parseOracleVerificationEvidence(text: string): OracleVerificationEvidence | undefined { + const trimmedText = text.trim() + if (!trimmedText) { + return undefined + } + + const agentMatch = trimmedText.match(AGENT_LINE_PATTERN) + if (!agentMatch) { + return undefined + } + const agent = agentMatch[1]?.trim() + if (!agent) { + return undefined + } + + const promiseMatch = trimmedText.match(PROMISE_TAG_PATTERN) + if (!promiseMatch) { + return undefined + } + const promise = promiseMatch[1]?.trim() + if (!promise) { + return undefined + } + + const metadataMatch = trimmedText.match(TASK_METADATA_PATTERN) + let sessionID: string | undefined + if (metadataMatch) { + const metadataContent = metadataMatch[1] + const sessionIDMatch = metadataContent.match(SESSION_ID_LINE_PATTERN) + if (sessionIDMatch) { + sessionID = sessionIDMatch[1]?.trim() + } + } + + return { agent, promise, sessionID } +} + +export function isOracleVerified(text: string): boolean { + const evidence = parseOracleVerificationEvidence(text) + if (!evidence) { + return false + } + + const isOracleAgent = evidence.agent.toLowerCase() === "oracle" + const isVerifiedPromise = evidence.promise === ULTRAWORK_VERIFICATION_PROMISE + + return isOracleAgent && isVerifiedPromise +} + +export function extractOracleSessionID(text: string): string | undefined { + const evidence = parseOracleVerificationEvidence(text) + if (!evidence || evidence.agent.toLowerCase() !== "oracle") { + return undefined + } + + return evidence.sessionID +} diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 00878ca91..420a2f935 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" -import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-detector" import type { RalphLoopState } from "./types" import { handleFailedVerification } from "./verification-failure-handler" import { withTimeout } from "./with-timeout" @@ -11,13 +11,6 @@ type OpenCodeSessionMessage = { parts?: Array<{ type?: string; text?: string }> } -const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i -const TASK_METADATA_SESSION_PATTERN = /[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i -const VERIFIED_PROMISE_PATTERN = new RegExp( - `\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`, - "i", -) - function collectAssistantText(message: OpenCodeSessionMessage): string { if (!Array.isArray(message.parts)) { return "" @@ -67,12 +60,11 @@ async function detectOracleVerificationFromParentSession( } const assistantText = collectAssistantText(message) - if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) { + if (!isOracleVerified(assistantText)) { continue } - const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN) - const detectedOracleSessionID = sessionMatch?.[1]?.trim() + const detectedOracleSessionID = extractOracleSessionID(assistantText) if (detectedOracleSessionID) { return detectedOracleSessionID } diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index 1f2edfa85..54041f452 100644 --- a/src/hooks/ralph-loop/ulw-loop-verification.test.ts +++ b/src/hooks/ralph-loop/ulw-loop-verification.test.ts @@ -279,8 +279,8 @@ describe("ulw-loop verification", () => { await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) expect(hook.getState()?.iteration).toBe(2) - expect(hook.getState()?.max_iterations).toBeUndefined() - expect(promptCalls[0].text).toContain("2/unbounded") + expect(hook.getState()?.max_iterations).toBe(500) + expect(promptCalls[0].text).toContain("2/500") }) test("#given prior transcript completion from older run #when new ulw loop starts #then old completion is ignored", async () => { diff --git a/src/hooks/read-image-resizer/hook.test.ts b/src/hooks/read-image-resizer/hook.test.ts index 548b44a43..5f199ad81 100644 --- a/src/hooks/read-image-resizer/hook.test.ts +++ b/src/hooks/read-image-resizer/hook.test.ts @@ -234,7 +234,7 @@ describe("createReadImageResizerHook", () => { expect(output.output).toContain("resized") }) - it("keeps original attachment URL and marks resize skipped when resize fails", async () => { + it("removes oversized attachment when resize fails to prevent API error", async () => { //#given mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) @@ -252,8 +252,37 @@ describe("createReadImageResizerHook", () => { await hook["tool.execute.after"](createInput("Read"), output) //#then - expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") - expect(output.output).toContain("resize skipped") + expect(output.attachments?.length ?? 0).toBe(0) + expect(output.output).toContain("exceeds provider limits") + expect(output.output).toContain("image removed to prevent API error") + }) + + it("removes only oversized attachments and preserves valid ones in mixed batches", async () => { + //#given + mockParseImageDimensions + .mockReturnValueOnce({ width: 800, height: 600 }) + .mockReturnValueOnce({ width: 4000, height: 3000 }) + mockCalculateTargetDimensions.mockReturnValueOnce(null).mockReturnValueOnce({ width: 1568, height: 1176 }) + mockResizeImage.mockResolvedValueOnce(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [ + { mime: "image/png", url: "data:image/png;base64,small", filename: "small.png" }, + { mime: "image/png", url: "data:image/png;base64,big", filename: "big.png" }, + ], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.length).toBe(1) + expect(output.attachments?.[0]?.filename).toBe("small.png") + expect(output.output).toContain("exceeds provider limits") }) it("appends unknown-dimensions metadata when parsing fails", async () => { diff --git a/src/hooks/read-image-resizer/hook.ts b/src/hooks/read-image-resizer/hook.ts index e5a199ae8..a537dca87 100644 --- a/src/hooks/read-image-resizer/hook.ts +++ b/src/hooks/read-image-resizer/hook.ts @@ -86,7 +86,7 @@ function formatResizeAppendix(entries: ResizeEntry[]): string { } if (entry.status === "resize-skipped") { - lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + lines.push(`- ${entry.filename}: ${originalText} (exceeds provider limits, image removed to prevent API error)`) continue } @@ -138,6 +138,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) { } const entries: ResizeEntry[] = [] + const attachmentsToRemove: ImageAttachment[] = [] for (const [index, attachment] of attachments.entries()) { const filename = resolveFilename(attachment, index) @@ -161,6 +162,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) { const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims) if (!resizedResult) { + attachmentsToRemove.push(attachment) entries.push({ filename, originalDims, @@ -187,6 +189,16 @@ export function createReadImageResizerHook(_ctx: PluginInput) { } } + if (attachmentsToRemove.length > 0) { + const rawAttachments = outputRecord.attachments as unknown[] + for (const toRemove of attachmentsToRemove) { + const removeIndex = rawAttachments.indexOf(toRemove) + if (removeIndex !== -1) { + rawAttachments.splice(removeIndex, 1) + } + } + } + if (entries.length === 0) { return } diff --git a/src/hooks/read-image-resizer/image-resizer.test.ts b/src/hooks/read-image-resizer/image-resizer.test.ts index a885932b3..1bdf3f1a0 100644 --- a/src/hooks/read-image-resizer/image-resizer.test.ts +++ b/src/hooks/read-image-resizer/image-resizer.test.ts @@ -1,6 +1,7 @@ /// import { afterEach, describe, expect, it, mock } from "bun:test" +import { deflateSync } from "node:zlib" const PNG_1X1_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -11,6 +12,73 @@ async function importFreshImageResizerModule(): Promise { return import(`./image-resizer?test-${Date.now()}-${Math.random()}`) } +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function testCrc32(data: Buffer): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function testCreateChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcInput = Buffer.concat([typeBuffer, data]) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(testCrc32(crcInput) >>> 0, 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +function createOversizedPngDataUrl(width: number, height: number): string { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + ihdr[10] = 0 + ihdr[11] = 0 + ihdr[12] = 0 + + const rowBytes = width * 4 + const rawData = Buffer.alloc(height * (rowBytes + 1)) + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + rawData[rowOffset] = 0 + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + 1 + x * 4 + rawData[pixelOffset] = (x * 255) % 256 + rawData[pixelOffset + 1] = (y * 255) % 256 + rawData[pixelOffset + 2] = ((x + y) * 127) % 256 + rawData[pixelOffset + 3] = 255 + } + } + + const idat = deflateSync(rawData) + const buffer = Buffer.concat([ + PNG_SIGNATURE, + testCreateChunk("IHDR", ihdr), + testCreateChunk("IDAT", idat), + testCreateChunk("IEND", Buffer.alloc(0)), + ]) + + return `data:image/png;base64,${buffer.toString("base64")}` +} + describe("calculateTargetDimensions", () => { it("returns null when dimensions are already within limits", async () => { //#given @@ -90,7 +158,28 @@ describe("resizeImage", () => { mock.restore() }) - it("returns null when sharp import fails", async () => { + it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => { + //#given + mock.module("sharp", () => { + throw new Error("sharp unavailable") + }) + const { resizeImage } = await importFreshImageResizerModule() + const oversizedPng = createOversizedPngDataUrl(3000, 2000) + + //#when + const result = await resizeImage(oversizedPng, "image/png", { + width: 1568, + height: 1045, + }) + + //#then + expect(result).not.toBeNull() + expect(result?.resized).toEqual({ width: 1568, height: 1045 }) + expect(result?.original).toEqual({ width: 3000, height: 2000 }) + expect(result?.resizedDataUrl).toStartWith("data:image/png;base64,") + }) + + it("returns null for non-PNG when sharp is unavailable", async () => { //#given mock.module("sharp", () => { throw new Error("sharp unavailable") @@ -98,7 +187,7 @@ describe("resizeImage", () => { const { resizeImage } = await importFreshImageResizerModule() //#when - const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", { width: 1, height: 1, }) @@ -107,6 +196,25 @@ describe("resizeImage", () => { expect(result).toBeNull() }) + it("falls back to pure-JS resizer when sharp has unexpected shape", async () => { + //#given + mock.module("sharp", () => ({ + default: "not-a-function", + })) + const { resizeImage } = await importFreshImageResizerModule() + const oversizedPng = createOversizedPngDataUrl(2000, 1000) + + //#when + const result = await resizeImage(oversizedPng, "image/png", { + width: 1568, + height: 784, + }) + + //#then + expect(result).not.toBeNull() + expect(result?.resized).toEqual({ width: 1568, height: 784 }) + }) + it("returns null when sharp throws during resize", async () => { //#given const mockSharpFactory = mock(() => ({ diff --git a/src/hooks/read-image-resizer/image-resizer.ts b/src/hooks/read-image-resizer/image-resizer.ts index 7ced5a9e8..13e2923f2 100644 --- a/src/hooks/read-image-resizer/image-resizer.ts +++ b/src/hooks/read-image-resizer/image-resizer.ts @@ -1,6 +1,7 @@ import type { ImageDimensions, ResizeResult } from "./types" import { extractBase64Data } from "../../tools/look-at/mime-type-inference" import { log } from "../../shared" +import { resizeImageFallback } from "./png-fallback-resizer" const ANTHROPIC_MAX_LONG_EDGE = 1568 const ANTHROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024 @@ -114,14 +115,14 @@ export async function resizeImage( const sharpModuleName = "sharp" const sharpModule = await import(sharpModuleName).catch(() => null) if (!sharpModule) { - log("[read-image-resizer] sharp unavailable, skipping resize") - return null + log("[read-image-resizer] sharp unavailable, attempting pure-JS fallback") + return resizeImageFallback(base64DataUrl, mimeType, target) } const sharpFactory = resolveSharpFactory(sharpModule) if (!sharpFactory) { - log("[read-image-resizer] sharp import has unexpected shape") - return null + log("[read-image-resizer] sharp import has unexpected shape, attempting pure-JS fallback") + return resizeImageFallback(base64DataUrl, mimeType, target) } const rawBase64 = extractBase64Data(base64DataUrl) diff --git a/src/hooks/read-image-resizer/png-fallback-resizer.test.ts b/src/hooks/read-image-resizer/png-fallback-resizer.test.ts new file mode 100644 index 000000000..9eff5f7f4 --- /dev/null +++ b/src/hooks/read-image-resizer/png-fallback-resizer.test.ts @@ -0,0 +1,146 @@ +/// + +import { describe, expect, it } from "bun:test" +import { deflateSync } from "node:zlib" + +import { resizeImageFallback } from "./png-fallback-resizer" +import { parseImageDimensions } from "./image-dimensions" + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function crc32(data: Buffer): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function createChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcInput = Buffer.concat([typeBuffer, data]) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc32(crcInput) >>> 0, 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +function createValidRgbaPng(width: number, height: number): string { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + ihdr[10] = 0 + ihdr[11] = 0 + ihdr[12] = 0 + + const rowBytes = width * 4 + const rawData = Buffer.alloc(height * (rowBytes + 1)) + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + rawData[rowOffset] = 0 + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + 1 + x * 4 + rawData[pixelOffset] = (x * 255) % 256 + rawData[pixelOffset + 1] = (y * 255) % 256 + rawData[pixelOffset + 2] = ((x + y) * 127) % 256 + rawData[pixelOffset + 3] = 255 + } + } + + const idat = deflateSync(rawData) + const buffer = Buffer.concat([ + PNG_SIGNATURE, + createChunk("IHDR", ihdr), + createChunk("IDAT", idat), + createChunk("IEND", Buffer.alloc(0)), + ]) + + return `data:image/png;base64,${buffer.toString("base64")}` +} + +describe("resizeImageFallback", () => { + describe("#given a valid RGBA PNG larger than the target", () => { + it("#when called #then returns a smaller PNG with target dimensions", () => { + //#given + const sourcePng = createValidRgbaPng(2000, 1500) + + //#when + const result = resizeImageFallback(sourcePng, "image/png", { width: 1568, height: 1176 }) + + //#then + expect(result).not.toBeNull() + expect(result?.original).toEqual({ width: 2000, height: 1500 }) + expect(result?.resized).toEqual({ width: 1568, height: 1176 }) + + const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png") + expect(parsed).toEqual({ width: 1568, height: 1176 }) + }) + + it("#when target is much smaller #then produces a valid PNG decodable by parser", () => { + //#given + const sourcePng = createValidRgbaPng(800, 800) + + //#when + const result = resizeImageFallback(sourcePng, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).not.toBeNull() + const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png") + expect(parsed).toEqual({ width: 100, height: 100 }) + }) + }) + + describe("#given a non-PNG mime type", () => { + it("#when called #then returns null", () => { + //#given + const sourcePng = createValidRgbaPng(2000, 1500) + + //#when + const result = resizeImageFallback(sourcePng, "image/jpeg", { width: 1568, height: 1176 }) + + //#then + expect(result).toBeNull() + }) + }) + + describe("#given an invalid PNG buffer", () => { + it("#when called #then returns null", () => { + //#given + const invalidPng = "data:image/png;base64,AAAA" + + //#when + const result = resizeImageFallback(invalidPng, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).toBeNull() + }) + }) + + describe("#given empty base64 data", () => { + it("#when called #then returns null", () => { + //#given + const empty = "data:image/png;base64," + + //#when + const result = resizeImageFallback(empty, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).toBeNull() + }) + }) +}) diff --git a/src/hooks/read-image-resizer/png-fallback-resizer.ts b/src/hooks/read-image-resizer/png-fallback-resizer.ts new file mode 100644 index 000000000..cbfc48bf7 --- /dev/null +++ b/src/hooks/read-image-resizer/png-fallback-resizer.ts @@ -0,0 +1,359 @@ +import { inflateSync, deflateSync } from "node:zlib" + +import type { ImageDimensions, ResizeResult } from "./types" +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" +import { log } from "../../shared" + +interface PngChunk { + type: string + data: Buffer + crc: Buffer +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function readPngChunks(buffer: Buffer): PngChunk[] { + const chunks: PngChunk[] = [] + let offset = 8 + + while (offset < buffer.length) { + if (offset + 8 > buffer.length) { + break + } + + const length = buffer.readUInt32BE(offset) + const type = buffer.toString("ascii", offset + 4, offset + 8) + const dataStart = offset + 8 + const dataEnd = dataStart + length + + if (dataEnd + 4 > buffer.length) { + break + } + + const data = buffer.subarray(dataStart, dataEnd) + const crc = buffer.subarray(dataEnd, dataEnd + 4) + chunks.push({ type, data, crc }) + offset = dataEnd + 4 + } + + return chunks +} + +function parseIhdr(data: Buffer): { width: number; height: number; bitDepth: number; colorType: number } | null { + if (data.length < 13) { + return null + } + + return { + width: data.readUInt32BE(0), + height: data.readUInt32BE(4), + bitDepth: data[8], + colorType: data[9], + } +} + +function getBytesPerPixel(colorType: number, bitDepth: number): number | null { + const channels: Record = { + 0: 1, // grayscale + 2: 3, // RGB + 4: 2, // grayscale + alpha + 6: 4, // RGBA + } + + const channelCount = channels[colorType] + if (channelCount === undefined) { + return null + } + + return channelCount * (bitDepth / 8) +} + +function paethPredictor(a: number, b: number, c: number): number { + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + + if (pa <= pb && pa <= pc) { + return a + } + + if (pb <= pc) { + return b + } + + return c +} + +function unfilterRow( + filterType: number, + currentRow: Buffer, + previousRow: Buffer | null, + bytesPerPixel: number, +): Buffer { + const result = Buffer.alloc(currentRow.length) + + for (let i = 0; i < currentRow.length; i++) { + const raw = currentRow[i] + const a = i >= bytesPerPixel ? result[i - bytesPerPixel] : 0 + const b = previousRow ? previousRow[i] : 0 + const c = i >= bytesPerPixel && previousRow ? previousRow[i - bytesPerPixel] : 0 + + switch (filterType) { + case 0: + result[i] = raw + break + case 1: + result[i] = (raw + a) & 0xff + break + case 2: + result[i] = (raw + b) & 0xff + break + case 3: + result[i] = (raw + Math.floor((a + b) / 2)) & 0xff + break + case 4: + result[i] = (raw + paethPredictor(a, b, c)) & 0xff + break + default: + result[i] = raw + } + } + + return result +} + +function decodePngPixels( + idatData: Buffer, + width: number, + height: number, + bytesPerPixel: number, +): Buffer | null { + try { + const decompressed = inflateSync(idatData) + const rowBytes = width * bytesPerPixel + const expectedLength = height * (rowBytes + 1) + + if (decompressed.length < expectedLength) { + return null + } + + const pixels = Buffer.alloc(width * height * bytesPerPixel) + let previousRow: Buffer | null = null + + for (let y = 0; y < height; y++) { + const rowStart = y * (rowBytes + 1) + const filterType = decompressed[rowStart] + const filteredRow = decompressed.subarray(rowStart + 1, rowStart + 1 + rowBytes) + const unfilteredRow = unfilterRow(filterType, filteredRow, previousRow, bytesPerPixel) + + unfilteredRow.copy(pixels, y * rowBytes) + previousRow = unfilteredRow + } + + return pixels + } catch { + return null + } +} + +function nearestNeighborResize( + sourcePixels: Buffer, + srcWidth: number, + srcHeight: number, + dstWidth: number, + dstHeight: number, + bytesPerPixel: number, +): Buffer { + const destPixels = Buffer.alloc(dstWidth * dstHeight * bytesPerPixel) + + for (let dstY = 0; dstY < dstHeight; dstY++) { + const srcY = Math.min(Math.floor((dstY * srcHeight) / dstHeight), srcHeight - 1) + + for (let dstX = 0; dstX < dstWidth; dstX++) { + const srcX = Math.min(Math.floor((dstX * srcWidth) / dstWidth), srcWidth - 1) + const srcOffset = (srcY * srcWidth + srcX) * bytesPerPixel + const dstOffset = (dstY * dstWidth + dstX) * bytesPerPixel + + for (let b = 0; b < bytesPerPixel; b++) { + destPixels[dstOffset + b] = sourcePixels[srcOffset + b] + } + } + } + + return destPixels +} + +function encodePng( + pixels: Buffer, + width: number, + height: number, + bitDepth: number, + colorType: number, + bytesPerPixel: number, +): Buffer { + const rowBytes = width * bytesPerPixel + const filteredData = Buffer.alloc(height * (rowBytes + 1)) + + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + filteredData[rowOffset] = 0 + pixels.copy(filteredData, rowOffset + 1, y * rowBytes, (y + 1) * rowBytes) + } + + const compressedData = deflateSync(filteredData) + + const ihdrData = Buffer.alloc(13) + ihdrData.writeUInt32BE(width, 0) + ihdrData.writeUInt32BE(height, 4) + ihdrData[8] = bitDepth + ihdrData[9] = colorType + ihdrData[10] = 0 + ihdrData[11] = 0 + ihdrData[12] = 0 + + const ihdrChunk = createChunk("IHDR", ihdrData) + const idatChunk = createChunk("IDAT", compressedData) + const iendChunk = createChunk("IEND", Buffer.alloc(0)) + + return Buffer.concat([PNG_SIGNATURE, ihdrChunk, idatChunk, iendChunk]) +} + +function createChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + + const crcInput = Buffer.concat([typeBuffer, data]) + const crc = crc32(crcInput) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc >>> 0, 0) + + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +const CRC_TABLE = buildCrcTable() + +function buildCrcTable(): Uint32Array { + const table = new Uint32Array(256) + + for (let n = 0; n < 256; n++) { + let c = n + + for (let k = 0; k < 8; k++) { + if (c & 1) { + c = 0xedb88320 ^ (c >>> 1) + } else { + c = c >>> 1 + } + } + + table[n] = c + } + + return table +} + +function crc32(data: Buffer): number { + let crc = 0xffffffff + + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + + return (crc ^ 0xffffffff) >>> 0 +} + +export function resizeImageFallback( + base64DataUrl: string, + mimeType: string, + target: ImageDimensions, +): ResizeResult | null { + if (mimeType.toLowerCase() !== "image/png") { + return null + } + + try { + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const inputBuffer = Buffer.from(rawBase64, "base64") + if (inputBuffer.length < 8) { + return null + } + + const signature = inputBuffer.subarray(0, 8) + if (!signature.equals(PNG_SIGNATURE)) { + return null + } + + const chunks = readPngChunks(inputBuffer) + const ihdrChunk = chunks.find((c) => c.type === "IHDR") + if (!ihdrChunk) { + return null + } + + const ihdr = parseIhdr(ihdrChunk.data) + if (!ihdr) { + return null + } + + const bytesPerPixel = getBytesPerPixel(ihdr.colorType, ihdr.bitDepth) + if (!bytesPerPixel) { + log("[png-fallback-resizer] unsupported color type or bit depth", { + colorType: ihdr.colorType, + bitDepth: ihdr.bitDepth, + }) + return null + } + + if (ihdr.bitDepth !== 8) { + log("[png-fallback-resizer] only 8-bit depth supported for fallback", { + bitDepth: ihdr.bitDepth, + }) + return null + } + + const idatChunks = chunks.filter((c) => c.type === "IDAT") + if (idatChunks.length === 0) { + return null + } + + const idatData = Buffer.concat(idatChunks.map((c) => c.data)) + const sourcePixels = decodePngPixels(idatData, ihdr.width, ihdr.height, bytesPerPixel) + if (!sourcePixels) { + return null + } + + const resizedPixels = nearestNeighborResize( + sourcePixels, + ihdr.width, + ihdr.height, + target.width, + target.height, + bytesPerPixel, + ) + + const outputBuffer = encodePng( + resizedPixels, + target.width, + target.height, + ihdr.bitDepth, + ihdr.colorType, + bytesPerPixel, + ) + + return { + resizedDataUrl: `data:image/png;base64,${outputBuffer.toString("base64")}`, + original: { width: ihdr.width, height: ihdr.height }, + resized: { width: target.width, height: target.height }, + } + } catch (error) { + log("[png-fallback-resizer] resize failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index de946af5b..cbb3be2be 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -9,7 +9,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { buildRetryModelPayload } from "./retry-model-payload" import { getLastUserRetryParts } from "./last-user-retry-parts" import { extractSessionMessages } from "./session-messages" -import { getAgentDisplayName } from "../../shared/agent-display-names" +import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" const SESSION_TTL_MS = 30 * 60 * 1000 @@ -133,14 +133,14 @@ export function createAutoRetryHelpers(deps: HookDeps) { }) const retryAgent = resolvedAgent ?? getSessionAgent(sessionID) + const launchAgent = resolveRegisteredAgentName(retryAgent) sessionAwaitingFallbackResult.add(sessionID) scheduleSessionFallbackTimeout(sessionID, retryAgent) await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - // Use config key to avoid HTTP header validation issues with display names - ...(retryAgent ? { agent: retryAgent } : {}), + ...(launchAgent ? { agent: launchAgent } : {}), ...retryModelPayload, parts: retryParts, }, diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index a42b10923..19a7cad56 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -27,15 +27,11 @@ export const RETRYABLE_ERROR_PATTERNS = [ /too.?many.?requests/i, /quota\s+will\s+reset\s+after/i, /quota.?exceeded/i, - /(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i, /exhausted\s+your\s+capacity/i, - /usage\s+limit\s+has\s+been\s+reached/i, /all\s+credentials\s+for\s+model/i, /cool(?:ing)?\s+down/i, /model.{0,20}?not.{0,10}?supported/i, /model_not_supported/i, - /insufficient.?(?:credits?|funds?|balance)/i, - /credit.*balance.*too.*low/i, /service.?unavailable/i, /overloaded/i, /temporarily.?unavailable/i, diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 962e831b1..7ba5aa491 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -131,7 +131,8 @@ export function classifyErrorType(error: unknown): string | undefined { /billing.?(?:hard.?)?limit/i.test(message) || /exhausted\s+your\s+capacity/i.test(message) || /out\s+of\s+credits?/i.test(message) || - /payment.?required/i.test(message) + /payment.?required/i.test(message) || + /usage\s+limit/i.test(message) ) { return "quota_exceeded" } diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index a1b52c96e..df4f7cd3c 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -282,7 +282,7 @@ describe("runtime-fallback", () => { expect(errorLog).toBeDefined() }) - test("should trigger fallback when session.error says you've reached your usage limit", async () => { + test("should NOT trigger fallback for quota exhaustion without auto-retry signal (STOP classification)", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig({ notify_on_fallback: false }), pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]), @@ -308,11 +308,10 @@ describe("runtime-fallback", () => { }) const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback")) - expect(fallbackLog).toBeDefined() - expect(fallbackLog?.data).toMatchObject({ from: "kimi-for-coding/k2p5", to: "zai-coding-plan/glm-5.1" }) + expect(fallbackLog).toBeUndefined() const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable")) - expect(skipLog).toBeUndefined() + expect(skipLog).toBeDefined() }) test("should continue fallback chain when fallback model is not found", async () => { @@ -519,7 +518,7 @@ describe("runtime-fallback", () => { test("should trigger fallback on OpenAI auto-retry signal in message.updated", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { - config: createMockConfig({ notify_on_fallback: false }), + config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }), pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]), }) @@ -2061,7 +2060,7 @@ describe("runtime-fallback", () => { expect(retriedModels).toContain("openai/gpt-5.3-codex") }) - test("triggers fallback when message contains type:error parts (e.g. Minimax insufficient balance)", async () => { + test("does NOT trigger fallback for quota exhaustion in error parts without auto-retry signal (STOP classification)", async () => { const retriedModels: string[] = [] const hook = createRuntimeFallbackHook( @@ -2109,7 +2108,10 @@ describe("runtime-fallback", () => { }, }) - expect(retriedModels).toContain("openai/gpt-5.4") + expect(retriedModels).toHaveLength(0) + + const skipLog = logCalls.find((c) => c.msg.includes("message.updated error not retryable")) + expect(skipLog).toBeDefined() }) test("triggers fallback when message has mixed text and error parts", async () => { diff --git a/src/hooks/runtime-fallback/provider-matrix.test.ts b/src/hooks/runtime-fallback/provider-matrix.test.ts new file mode 100644 index 000000000..d94986e78 --- /dev/null +++ b/src/hooks/runtime-fallback/provider-matrix.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, test } from "bun:test" + +import { classifyErrorType, isRetryableError } from "./error-classifier" + +describe("runtime-fallback provider matrix quota tests", () => { + describe("OpenAI provider", () => { + test("classifies OpenAI insufficient_quota error as quota_exceeded", () => { + //#given + const error = { + name: "InsufficientQuotaError", + message: "You exceeded your current quota. Please check your plan and billing details.", + provider: "openai", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies OpenAI billing_hard_limit error as quota_exceeded", () => { + //#given + const error = { + name: "BillingError", + message: "Billing hard limit reached. You have exceeded your hard limit.", + provider: "openai", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies OpenAI rate limit as retryable", () => { + //#given + const error = { + name: "RateLimitError", + statusCode: 429, + message: "Rate limit reached for requests", + provider: "openai", + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) + + describe("Anthropic provider", () => { + test("classifies Anthropic quota exceeded as non-retryable", () => { + //#given + const error = { + name: "QuotaExceededError", + message: "Your account has exceeded its quota. Please upgrade your plan.", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Anthropic subscription quota as non-retryable", () => { + //#given + const error = { + name: "AI_APICallError", + message: "Subscription quota exceeded. You can continue using free models.", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Anthropic cooling down with retry signal as retryable (auto-retry pattern)", () => { + //#given + const error = { + name: "AI_APICallError", + message: "All credentials for model claude-opus-4-6 are cooling down [retrying in ~2 weeks]", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBeUndefined() + expect(retryable).toBe(true) + }) + }) + + describe("Google/Gemini provider", () => { + test("classifies Google API key missing as missing_api_key", () => { + //#given + const error = { + name: "AI_LoadAPIKeyError", + message: + "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.", + provider: "google", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("missing_api_key") + expect(retryable).toBe(true) + }) + + test("classifies Google quota exceeded as quota_exceeded", () => { + //#given + const error = { + name: "QuotaExceededError", + message: "Quota exceeded for quota metric 'Generate Content API requests'", + provider: "google", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Google rate limit exceeded as retryable", () => { + //#given + const error = { + name: "ResourceExhausted", + statusCode: 429, + message: "Rate limit exceeded. Please try again later.", + provider: "google", + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) + + describe("Generic provider patterns", () => { + test("classifies exhausted capacity as quota_exceeded", () => { + //#given + const error = { + message: "Sorry, you've exhausted your capacity", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies out of credits as quota_exceeded", () => { + //#given + const error = { + message: "You are out of credits. Please purchase more.", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies payment required (402) as quota_exceeded", () => { + //#given + const error = { + statusCode: 402, + message: "Payment Required", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies out of credits as quota_exceeded", () => { + //#given + const error = { + message: "You are out of credits. Please purchase more.", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies exhausted capacity as quota_exceeded", () => { + //#given + const error = { + message: "Sorry, you've exhausted your capacity", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + }) + + describe("Provider-specific error name patterns", () => { + test("classifies BillingError as quota_exceeded", () => { + //#given + const error = { name: "BillingError", message: "Billing issue" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies InsufficientQuota as quota_exceeded", () => { + //#given + const error = { name: "InsufficientQuota", message: "Not enough quota" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies QuotaExceeded as quota_exceeded", () => { + //#given + const error = { name: "QuotaExceeded", message: "Quota limit reached" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + }) + + describe("HTTP status code matrix", () => { + test("429 rate limit is retryable", () => { + //#given + const error = { statusCode: 429, message: "Too many requests" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("402 payment required is NOT retryable", () => { + //#given + const error = { statusCode: 402, message: "Payment Required" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(false) + }) + + test("500 server error is retryable", () => { + //#given + const error = { statusCode: 500, message: "Internal Server Error" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("503 service unavailable is retryable", () => { + //#given + const error = { statusCode: 503, message: "Service Unavailable" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) +}) diff --git a/src/hooks/session-recovery/resume.test.ts b/src/hooks/session-recovery/resume.test.ts index fff669984..1c2c40c08 100644 --- a/src/hooks/session-recovery/resume.test.ts +++ b/src/hooks/session-recovery/resume.test.ts @@ -22,9 +22,35 @@ describe("session-recovery resume", () => { expect(config.tools).toEqual({ question: false, bash: true }) }) - test("resumeSession sends inherited tools with continuation prompt", async () => { + test("#given the last user message includes model variant #when extracting resume config #then the variant is preserved", () => { + // given + const model = { + providerID: "openai", + modelID: "gpt-5.3-codex", + variant: "max", + } + const userMessage: MessageData = { + info: { + agent: "Hephaestus", + model, + }, + } + + // when + const config = extractResumeConfig(userMessage, "ses_resume_variant") + + // then + expect(config.model).toEqual(model) + }) + + test("resumeSession sends inherited tools and variant with continuation prompt", async () => { // given let promptBody: Record | undefined + const model = { + providerID: "openai", + modelID: "gpt-5.3-codex", + variant: "max", + } const client = { session: { promptAsync: async (input: { body: Record }) => { @@ -38,12 +64,14 @@ describe("session-recovery resume", () => { const ok = await resumeSession(client as never, { sessionID: "ses_resume_prompt", agent: "Hephaestus", - model: { providerID: "openai", modelID: "gpt-5.3-codex" }, + model, tools: { question: false, bash: true }, }) // then expect(ok).toBe(true) + expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" }) + expect(promptBody?.variant).toBe("max") expect(promptBody?.tools).toEqual({ question: false, bash: true }) expect(Array.isArray(promptBody?.parts)).toBe(true) const firstPart = (promptBody?.parts as Array<{ text?: string }>)?.[0] diff --git a/src/hooks/session-recovery/resume.ts b/src/hooks/session-recovery/resume.ts index e5d187d79..6c42b6315 100644 --- a/src/hooks/session-recovery/resume.ts +++ b/src/hooks/session-recovery/resume.ts @@ -27,12 +27,18 @@ export function extractResumeConfig(userMessage: MessageData | undefined, sessio export async function resumeSession(client: Client, config: ResumeConfig): Promise { try { const inheritedTools = resolveInheritedPromptTools(config.sessionID, config.tools) + const launchModel = config.model + ? { providerID: config.model.providerID, modelID: config.model.modelID } + : undefined + const launchVariant = config.model?.variant + await client.session.promptAsync({ path: { id: config.sessionID }, body: { parts: [createInternalAgentTextPart(RECOVERY_RESUME_TEXT)], agent: config.agent, - model: config.model, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), }, }) diff --git a/src/hooks/session-recovery/types.ts b/src/hooks/session-recovery/types.ts index 74730f54e..3485d62b6 100644 --- a/src/hooks/session-recovery/types.ts +++ b/src/hooks/session-recovery/types.ts @@ -73,6 +73,7 @@ export interface MessageData { model?: { providerID: string modelID: string + variant?: string } system?: string tools?: Record @@ -94,6 +95,7 @@ export interface ResumeConfig { model?: { providerID: string modelID: string + variant?: string } tools?: Record } diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 17642ca73..ecbe1d37a 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -17,12 +17,33 @@ import { createWorktreeActiveBlock } from "./worktree-block" import type { PluginInput } from "@opencode-ai/plugin" import { HOOK_NAME } from "./start-work-hook" +function normalizePlanLookupValue(value: string): string { + return value + .trim() + .replace(/^["'`]+|["'`]+$/g, "") + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^\p{L}\p{N}-]+/gu, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + function findPlanByName(plans: string[], requestedName: string): string | null { const lowerName = requestedName.toLowerCase() + const normalizedRequestedName = normalizePlanLookupValue(requestedName) const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName) if (exactMatch) return exactMatch + const normalizedExactMatch = plans.find((planPath) => + normalizePlanLookupValue(getPlanName(planPath)) === normalizedRequestedName, + ) + if (normalizedExactMatch) return normalizedExactMatch const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName)) - return partialMatch || null + if (partialMatch) return partialMatch + + const normalizedPartialMatch = plans.find((planPath) => + normalizePlanLookupValue(getPlanName(planPath)).includes(normalizedRequestedName), + ) + return normalizedPartialMatch || null } function buildAutoSelectedPlanContext(params: { diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 9957b9608..63f37f06d 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -7,7 +7,6 @@ import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { createStartWorkHook } from "./index" import { createAtlasHook } from "../atlas" -import { getAgentListDisplayName } from "../../shared/agent-display-names" import { writeBoulderState, clearBoulderState, @@ -415,6 +414,151 @@ You are starting a Sisyphus work session. expect(output.parts[0].text).toContain("2026-01-15-feature-implementation") expect(output.parts[0].text).toContain("Auto-Selected Plan") }) + + test("should match quoted human-readable plan names to slugged filenames", async () => { + // given - saved plan uses a slugged filename + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "my-feature-plan.md") + writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "\"my feature plan\"" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-123" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("my-feature-plan") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match Korean plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "결제-플로우.md") + writeFileSync(planPath, "# 결제 플로우\n- [ ] 작업 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "결제 플로우" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-korean-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("결제-플로우") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match Japanese plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "支払い-フロー.md") + writeFileSync(planPath, "# 支払い フロー\n- [ ] タスク 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "支払い フロー" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-japanese-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("支払い-フロー") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should keep ASCII plan name matching behavior unchanged", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "checkout-flow.md") + writeFileSync(planPath, "# Checkout Flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "checkout flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-ascii-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("checkout-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match mixed ASCII and non-ASCII plan names", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "v2-결제-flow.md") + writeFileSync(planPath, "# v2 결제 flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "v2 결제 flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-mixed-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("v2-결제-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) }) describe("session agent management", () => { @@ -438,7 +582,7 @@ You are starting a Sisyphus work session. updateSpy.mockRestore() }) - test("should stamp the outgoing message with Atlas list key so follow-up events keep the handoff", async () => { + test("should stamp the outgoing message with Atlas config key so OpenCode can resolve the agent", async () => { // given const hook = createStartWorkHook(createMockPluginInput()) const output = { @@ -452,7 +596,7 @@ You are starting a Sisyphus work session. output ) - // then + // then - config key, not display name (matches no-sisyphus-gpt / boulder-continuation-injector convention) expect(output.message.agent).toBe("atlas") }) diff --git a/src/hooks/start-work/parse-user-request.test.ts b/src/hooks/start-work/parse-user-request.test.ts index e5d61a4c5..b675faa76 100644 --- a/src/hooks/start-work/parse-user-request.test.ts +++ b/src/hooks/start-work/parse-user-request.test.ts @@ -50,6 +50,14 @@ describe("parseUserRequest", () => { }) }) + describe("when plan name is wrapped in quotes", () => { + test("#given quoted plan name #when parsing #then strips wrapping quotes", () => { + const result = parseUserRequest("\"my feature plan\"") + expect(result.planName).toBe("my feature plan") + expect(result.explicitWorktreePath).toBeNull() + }) + }) + describe("when --worktree flag has no path", () => { test("#given --worktree without path #when parsing #then worktree path is null", () => { const result = parseUserRequest("--worktree") diff --git a/src/hooks/start-work/parse-user-request.ts b/src/hooks/start-work/parse-user-request.ts index 627deb67a..0dc56b78c 100644 --- a/src/hooks/start-work/parse-user-request.ts +++ b/src/hooks/start-work/parse-user-request.ts @@ -1,5 +1,6 @@ const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/ +const WRAPPING_QUOTES_PATTERN = /^(["'`])([\s\S]*)\1$/ export interface ParsedUserRequest { planName: string | null @@ -21,9 +22,11 @@ export function parseUserRequest(promptText: string): ParsedUserRequest { } const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() + const quotedPlanMatch = cleanedArg.match(WRAPPING_QUOTES_PATTERN) + const normalizedPlanName = quotedPlanMatch ? quotedPlanMatch[2].trim() : cleanedArg return { - planName: cleanedArg || null, + planName: normalizedPlanName || null, explicitWorktreePath, } } diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index 357f9552a..ec8a5011b 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -11,12 +11,9 @@ import { clearBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" -import { - getAgentDisplayName, - getAgentListDisplayName, -} from "../../shared/agent-display-names" import { isAgentRegistered, + resolveRegisteredAgentName, updateSessionAgent, } from "../../features/claude-code-session-state" import { detectWorktreePath } from "./worktree-detector" @@ -85,12 +82,9 @@ export function createStartWorkHook(ctx: PluginInput) { const activeAgent = isAgentRegistered("atlas") ? "atlas" : "sisyphus" - const activeAgentDisplayName = getAgentDisplayName(activeAgent) updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - // Use config key for agent field to avoid HTTP header validation issues - // Display names like "Atlas (Plan Executor)" contain parens that are invalid in headers - output.message["agent"] = activeAgent + output.message["agent"] = resolveRegisteredAgentName(activeAgent) ?? activeAgent } const existingState = readBoulderState(ctx.directory) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index b7015b517..56dd7cb4e 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -5,7 +5,7 @@ import { injectContinuation } from "./continuation-injection" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" describe("injectContinuation", () => { - test("normalizes built-in display names to config keys before promptAsync", async () => { + test("preserves the registered built-in agent name before promptAsync", async () => { // given let capturedAgent: string | undefined const ctx = { @@ -40,7 +40,7 @@ describe("injectContinuation", () => { }) // then - expect(capturedAgent).toBe("sisyphus") + expect(capturedAgent).toBe("Sisyphus - Ultraworker") }) test("inherits tools from resolved message info when reinjecting", async () => { @@ -119,4 +119,57 @@ describe("injectContinuation", () => { // then expect(injected).toBe(false) }) + + test("#given resolved model info includes variant #when reinjecting continuation #then promptAsync receives variant as a top-level field", async () => { + // given + let capturedBody: + | { + model?: { providerID: string; modelID: string } + variant?: string + } + | undefined + const ctx = { + directory: "/tmp/test", + client: { + session: { + todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }), + promptAsync: async (input: { + body: { + model?: { providerID: string; modelID: string } + variant?: string + } + }) => { + capturedBody = input.body + return {} + }, + }, + }, + } + const sessionStateStore = { + getExistingState: () => ({ inFlight: false, lastInjectedAt: 0, consecutiveFailures: 0 }), + } + const model = { + providerID: "openai", + modelID: "gpt-5.3-codex", + variant: "max", + } + + // when + await injectContinuation({ + ctx: ctx as never, + sessionID: "ses_continuation_variant", + resolvedInfo: { + agent: "Hephaestus", + model, + }, + sessionStateStore: sessionStateStore as never, + }) + + // then + expect(capturedBody?.model).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex", + }) + expect(capturedBody?.variant).toBe("max") + }) }) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index a65146ff9..5844bebd2 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,7 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { getSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { createInternalAgentTextPart, normalizeSDKResponse, @@ -26,6 +29,7 @@ import { } from "./constants" import { isCompactionGuardActive } from "./compaction-guard" import { getMessageDir } from "./message-directory" +import { isTokenLimitError } from "./token-limit-detection" import { getIncompleteCount } from "./todo" import type { ResolvedMessageInfo, Todo } from "./types" import type { SessionStateStore } from "./session-state" @@ -126,6 +130,7 @@ export async function injectContinuation(args: { } const promptAgent = normalizeAgentForPromptKey(agentName) + const launchAgent = resolveRegisteredAgentName(agentName) if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) @@ -167,18 +172,24 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, - agent: promptAgent, + agent: launchAgent ?? promptAgent, model, incompleteCount: freshIncompleteCount, }) const inheritedTools = resolveInheritedPromptTools(sessionID, tools) + const launchModel = model + ? { providerID: model.providerID, modelID: model.modelID } + : undefined + const launchVariant = model?.variant + await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: promptAgent, - ...(model !== undefined ? { model } : {}), + agent: launchAgent ?? promptAgent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), parts: [createInternalAgentTextPart(prompt)], }, @@ -198,6 +209,14 @@ ${todoList}` injectionState.inFlight = false injectionState.lastInjectedAt = Date.now() injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1 + + const errorObj = error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) } + if (isTokenLimitError(errorObj)) { + injectionState.tokenLimitDetected = true + log(`[${HOOK_NAME}] Token limit error detected during injection, stopping continuation`, { sessionID }) + } } } } diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index e94167501..3347ee666 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -11,6 +11,7 @@ import { armCompactionGuard } from "./compaction-guard" import type { SessionStateStore } from "./session-state" import { handleSessionIdle } from "./idle-event" import { handleNonIdleEvent } from "./non-idle-events" +import { isTokenLimitError } from "./token-limit-detection" export function createTodoContinuationHandler(args: { ctx: PluginInput @@ -34,7 +35,7 @@ export function createTodoContinuationHandler(args: { const sessionID = props?.sessionID as string | undefined if (!sessionID) return - const error = props?.error as { name?: string } | undefined + const error = props?.error as { name?: string; message?: string } | undefined if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { const state = sessionStateStore.getState(sessionID) state.wasCancelled = true @@ -45,6 +46,10 @@ export function createTodoContinuationHandler(args: { state.stagnationCount = 0 state.consecutiveFailures = 0 log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) + } else if (isTokenLimitError(error)) { + const state = sessionStateStore.getState(sessionID) + state.tokenLimitDetected = true + log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message }) } sessionStateStore.cancelCountdown(sessionID) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 9c1f2533c..162b60f6d 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -55,6 +55,11 @@ export async function handleSessionIdle(args: { return } + if (state.tokenLimitDetected) { + log(`[${HOOK_NAME}] Skipped: token limit error detected, retry would worsen context overflow`, { sessionID }) + return + } + if (state.abortDetectedAt) { const timeSinceAbort = Date.now() - state.abortDetectedAt if (timeSinceAbort < ABORT_WINDOW_MS) { @@ -145,14 +150,21 @@ export async function handleSessionIdle(args: { let resolvedInfo: ResolvedMessageInfo | undefined let encounteredCompaction = false + let latestMessageWasCompaction = false try { const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages) resolvedInfo = messageInfoResult.resolvedInfo encounteredCompaction = messageInfoResult.encounteredCompaction + latestMessageWasCompaction = messageInfoResult.latestMessageWasCompaction } catch (error) { log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) } + if (latestMessageWasCompaction) { + log(`[${HOOK_NAME}] Skipped: latest message is a compaction marker`, { sessionID }) + return + } + const sessionAgent = getSessionAgent(sessionID) if (!resolvedInfo?.agent && sessionAgent) { resolvedInfo = { ...resolvedInfo, agent: sessionAgent } diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index b9f61f803..a88da8773 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -28,6 +28,7 @@ export function handleNonIdleEvent(args: { if (state) { state.abortDetectedAt = undefined state.wasCancelled = false + state.tokenLimitDetected = false sessionStateStore.recordActivity(sessionID) } sessionStateStore.cancelCountdown(sessionID) diff --git a/src/hooks/todo-continuation-enforcer/pending-question-detection.ts b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts index fd97b6c35..7777da03b 100644 --- a/src/hooks/todo-continuation-enforcer/pending-question-detection.ts +++ b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts @@ -2,7 +2,7 @@ import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" interface MessagePart { - type: string + type?: string name?: string toolName?: string } diff --git a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts index bffd8cfd6..42431aa07 100644 --- a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts +++ b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { normalizeSDKResponse } from "../../shared" +import { isCompactionMessage } from "../../shared/compaction-marker" import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types" @@ -16,10 +17,17 @@ export async function resolveLatestMessageInfo( [] as MessageWithInfo[], ) let encounteredCompaction = false + let latestMessageWasCompaction = false for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { + const message = messages[i] + const info = message.info + const isCompaction = isCompactionMessage(message) + if (i === messages.length - 1) { + latestMessageWasCompaction = isCompaction + } + + if (isCompaction) { encounteredCompaction = true continue } @@ -31,9 +39,10 @@ export async function resolveLatestMessageInfo( tools: info.tools, }, encounteredCompaction, + latestMessageWasCompaction, } } } - return { resolvedInfo: undefined, encounteredCompaction } + return { resolvedInfo: undefined, encounteredCompaction, latestMessageWasCompaction } } diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index e126bfd1c..9c5a35f5c 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1594,8 +1594,8 @@ describe("todo-continuation-enforcer", () => { // when resolving agent info, preventing infinite continuation loops // ============================================================ - test("should skip compaction agent messages when resolving agent info", async () => { - // given - session where last message is from compaction agent but previous was Sisyphus + test("should skip injection while the latest message is from the compaction agent", async () => { + // given - session where the latest activity is still the compaction assistant turn const sessionID = "main-compaction-filter" setMainSession(sessionID) @@ -1644,9 +1644,8 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500) - // then - continuation uses Sisyphus (skipped compaction agent) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].agent).toBe("sisyphus") + // then - no continuation while compaction is still the latest event + expect(promptCalls).toHaveLength(0) }) test("should skip injection when only compaction agent messages exist", async () => { @@ -1702,6 +1701,62 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should skip compaction marker user messages when resolving agent info", async () => { + // given - latest user message is the OpenCode compaction marker, not a real turn + const sessionID = "main-compaction-marker-filter" + setMainSession(sessionID) + + const mockMessagesWithCompactionMarker = [ + { info: { id: "msg-1", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } }, + { + info: { id: "msg-2", role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5.4" } }, + parts: [{ type: "compaction" }], + }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesWithCompactionMarker }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + promptAsync: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, { + backgroundManager: createMockBackgroundManager(false), + }) + + // when - session goes idle + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(3000) + + // then - no continuation while the compaction marker is the latest event + expect(promptCalls).toHaveLength(0) + }) + test("should skip injection when prometheus agent is after compaction", async () => { // given - prometheus session that was compacted const sessionID = "main-prometheus-compacted" @@ -1962,5 +2017,188 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(1) }, { timeout: 20000 }) + // ============================================================ + // TOKEN-LIMIT ERROR DETECTION TESTS (#2462) + // These tests verify that the enforcer does NOT retry continuation + // when the model returns a token-limit / context-length error. + // ============================================================ + + test("should stop continuation when session.error carries a ContextLengthError", async () => { + // given - session with incomplete todos + const sessionID = "main-token-limit-event" + setMainSession(sessionID) + mockMessages = [ + { info: { id: "msg-1", role: "user" } }, + { info: { id: "msg-2", role: "assistant" } }, + ] + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - token limit error event fires + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID, + error: { name: "ContextLengthError", message: "prompt is too long: 250000 tokens > 200000 maximum" }, + }, + }, + }) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected (token limit error blocks retry) + expect(promptCalls).toHaveLength(0) + }) + + test("should stop continuation when session.error message contains token limit keywords", async () => { + // given - session with incomplete todos + const sessionID = "main-token-limit-message" + setMainSession(sessionID) + mockMessages = [ + { info: { id: "msg-1", role: "user" } }, + { info: { id: "msg-2", role: "assistant" } }, + ] + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - error with token limit message fires (no specific error name) + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID, + error: { name: "APIError", message: "context_length_exceeded: the prompt is too long" }, + }, + }, + }) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected + expect(promptCalls).toHaveLength(0) + }) + + test("should stop continuation when promptAsync throws a token-limit error", async () => { + // given - session where promptAsync will throw a token limit error + const sessionID = "main-token-limit-injection" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async () => { + const error = new Error("prompt is too long: 150000 tokens > 100000 maximum") + ;(error as any).name = "ContextLengthError" + throw error + } + + const hook = createTodoContinuationEnforcer(mockInput, {}) + + // when - first idle triggers injection that fails with token limit + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(2500, true) + + // when - wait past any cooldown, try again + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 100) + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(3000, true) + + // then - no second injection attempt (token limit permanently stops continuation) + expect(promptCalls).toHaveLength(0) + }) + + test("should still allow retries for non-token-limit errors (existing behavior)", async () => { + // given - session where promptAsync throws a generic error + const sessionID = "main-generic-error-retry" + setMainSession(sessionID) + let callCount = 0 + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: any) => { + callCount++ + if (callCount === 1) { + throw new Error("simulated network error") + } + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + } + + const hook = createTodoContinuationEnforcer(mockInput, {}) + + // when - first idle triggers injection that fails with generic error + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(2500, true) + + // when - wait past cooldown, try again + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2) + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(2500, true) + + // then - second attempt succeeds (generic errors still allow retry) + expect(callCount).toBe(2) + expect(promptCalls).toHaveLength(1) + }, { timeout: 30000 }) + + test("should clear token limit flag when user sends new message after recovery", async () => { + fakeTimers.restore() + // given - session that hit token limit + const sessionID = "main-token-limit-recovery" + setMainSession(sessionID) + mockMessages = [ + { info: { id: "msg-1", role: "user" } }, + { info: { id: "msg-2", role: "assistant" } }, + ] + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - token limit error fires + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID, + error: { name: "ContextLengthError", message: "prompt is too long" }, + }, + }, + }) + + // when - user sends new message (clears token limit flag via activity) + await hook.handler({ + event: { + type: "message.updated", + properties: { info: { sessionID, role: "user" } }, + }, + }) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await wait(2500) + + // then - continuation injected (token limit flag cleared by user activity) + expect(promptCalls.length).toBe(1) + }, { timeout: 15000 }) }) diff --git a/src/hooks/todo-continuation-enforcer/token-limit-detection.ts b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts new file mode 100644 index 000000000..366ac245f --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts @@ -0,0 +1,38 @@ +import { isRetryableModelError } from "../../shared/model-error-classifier" + +const TOKEN_LIMIT_FALLBACK_PATTERNS = [ + "prompt is too long", + "is too long", + "context_length_exceeded", + "token limit", + "context length", + "too many tokens", +] + +const TOKEN_LIMIT_ERROR_NAMES = new Set([ + "contextlengtherror", + "context_length_exceeded", +]) + +export function isTokenLimitError(error: { name?: string; message?: string } | undefined): boolean { + if (!error) return false + + const isRetryable = isRetryableModelError({ + name: error.name, + message: error.message, + }) + + if (!isRetryable && error.name) { + const errorNameLower = error.name.toLowerCase() + if (TOKEN_LIMIT_ERROR_NAMES.has(errorNameLower)) { + return true + } + } + + if (error.message) { + const lower = error.message.toLowerCase() + return TOKEN_LIMIT_FALLBACK_PATTERNS.some((pattern) => lower.includes(pattern)) + } + + return false +} diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index c773928c0..3d0e61770 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -27,6 +27,7 @@ export interface SessionState { countdownInterval?: ReturnType isRecovering?: boolean wasCancelled?: boolean + tokenLimitDetected?: boolean countdownStartedAt?: number abortDetectedAt?: number lastIncompleteCount?: number @@ -45,7 +46,7 @@ export interface MessageInfo { role?: string error?: { name?: string; data?: unknown } agent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } providerID?: string modelID?: string tools?: Record @@ -53,17 +54,19 @@ export interface MessageInfo { export interface MessageWithInfo { info?: MessageInfo + parts?: Array<{ type?: string }> } export interface ResolvedMessageInfo { agent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } tools?: Record } export interface ResolveLatestMessageInfoResult { resolvedInfo?: ResolvedMessageInfo encounteredCompaction: boolean + latestMessageWasCompaction: boolean } export interface ContinuationProgressOptions { diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index 38cd2a87b..ac62a4348 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -214,4 +214,45 @@ describe("unstable-agent-babysitter hook", () => { expect(promptCalls.length).toBe(1) Date.now = originalNow }) + + test("#given the main session model includes variant #when injecting a babysitter reminder #then promptAsync receives variant as a top-level field", async () => { + // given + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const mainModel = { + providerID: "openai", + modelID: "gpt-4", + variant: "max", + } + const ctx = createMockPluginInput({ + messagesBySession: { + "main-1": [ + { info: { agent: "sisyphus", model: mainModel } }, + ], + "bg-1": [ + { info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] }, + ], + }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask()]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(1) + const payload = promptCalls[0].input as { + body?: { + model?: { providerID: string; modelID: string } + variant?: string + } + } + expect(payload.body?.model).toEqual({ providerID: "openai", modelID: "gpt-4" }) + expect(payload.body?.variant).toBe("max") + }) }) diff --git a/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts index 8414c4ac1..1214d2cae 100644 --- a/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts +++ b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts @@ -5,7 +5,7 @@ export const THINKING_SUMMARY_MAX_CHARS = 500 as const type MessageInfo = { role?: string agent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } providerID?: string modelID?: string tools?: Record @@ -33,7 +33,11 @@ export function getMessageInfo(value: unknown): MessageInfo | undefined { ? info.model : undefined const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" - ? { providerID: modelValue.providerID, modelID: modelValue.modelID } + ? { + providerID: modelValue.providerID, + modelID: modelValue.modelID, + ...(typeof modelValue.variant === "string" ? { variant: modelValue.variant } : {}), + } : undefined return { role: typeof info.role === "string" ? info.role : undefined, diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 018236394..5821a1738 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -30,6 +30,7 @@ type BabysitterContext = { body: { parts: Array<{ type: "text"; text: string }> agent?: string + variant?: string model?: { providerID: string; modelID: string } tools?: Record } @@ -40,6 +41,7 @@ type BabysitterContext = { body: { parts: Array<{ type: "text"; text: string }> agent?: string + variant?: string model?: { providerID: string; modelID: string } tools?: Record } @@ -58,9 +60,9 @@ type BabysitterOptions = { async function resolveMainSessionTarget( ctx: BabysitterContext, sessionID: string -): Promise<{ agent?: string; model?: { providerID: string; modelID: string }; tools?: Record }> { +): Promise<{ agent?: string; model?: { providerID: string; modelID: string; variant?: string }; tools?: Record }> { let agent = getSessionAgent(sessionID) - let model: { providerID: string; modelID: string } | undefined + let model: { providerID: string; modelID: string; variant?: string } | undefined let tools: Record | undefined try { @@ -206,11 +208,17 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option const { agent, model, tools } = await resolveMainSessionTarget(ctx, mainSessionID) try { + const launchModel = model + ? { providerID: model.providerID, modelID: model.modelID } + : undefined + const launchVariant = model?.variant + await ctx.client.session.promptAsync({ path: { id: mainSessionID }, body: { ...(agent ? { agent } : {}), - ...(model ? { model } : {}), + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), ...(tools ? { tools } : {}), parts: [createInternalAgentTextPart(reminder)], }, diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts index b17d02faa..c557b7955 100644 --- a/src/plugin-handlers/agent-config-handler.test.ts +++ b/src/plugin-handlers/agent-config-handler.test.ts @@ -9,11 +9,11 @@ import type { OhMyOpenCodeConfig } from "../config" import * as agentLoader from "../features/claude-code-agent-loader" import * as skillLoader from "../features/opencode-skill-loader" import type { LoadedSkill } from "../features/opencode-skill-loader" -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName, getAgentDisplayName } from "../shared/agent-display-names" import { applyAgentConfig } from "./agent-config-handler" import type { PluginComponents } from "./plugin-components-loader" -const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentListDisplayName("sisyphus") +const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentDisplayName("sisyphus") const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior") const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker") @@ -158,6 +158,25 @@ describe("applyAgentConfig builtin override protection", () => { logSpy.mockRestore() }) + test("registered agent keys are HTTP-header-safe (no parentheses) for UI selector compatibility", async () => { + // given builtin agents are registered via applyAgentConfig + + // when applyAgentConfig runs + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then every registered agent key must be HTTP-header-safe (no parentheses) + // Parentheses in agent names cause HTTP header validation errors in + // x-opencode-agent-name and prevent the agents from showing in the OpenCode UI. + for (const key of Object.keys(result)) { + expect(key).not.toMatch(/[()]/) + } + }) + test("filters user agents whose key matches the builtin display-name alias", async () => { // given loadUserAgentsSpy.mockReturnValue({ @@ -293,6 +312,81 @@ describe("applyAgentConfig builtin override protection", () => { expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false) }) + test("defaults mode to subagent for configAgent entries missing mode", async () => { + // given + const config = createBaseConfig() + ;(config as Record).agent = { + "custom-reviewer": { + name: "custom-reviewer", + prompt: "Review code for security issues", + description: "Custom code reviewer", + }, + } + + // when + const result = await applyAgentConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + const customAgent = result["custom-reviewer"] as Record + expect(customAgent).toBeDefined() + expect(customAgent.mode).toBe("subagent") + }) + + test("preserves explicit mode on configAgent entries", async () => { + // given + const config = createBaseConfig() + ;(config as Record).agent = { + "custom-primary": { + name: "custom-primary", + prompt: "Primary agent", + mode: "primary", + }, + } + + // when + const result = await applyAgentConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + const customAgent = result["custom-primary"] as Record + expect(customAgent).toBeDefined() + expect(customAgent.mode).toBe("primary") + }) + + test("defaults mode to subagent for plugin agents missing mode", async () => { + // given + const pluginComponents = createPluginComponents() + pluginComponents.agents = { + "plugin-worker": { + name: "plugin-worker", + prompt: "Do work", + description: "Plugin worker agent", + } as Record, + } + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents, + }) + + // then + const pluginAgent = result["plugin-worker"] as Record + expect(pluginAgent).toBeDefined() + expect(pluginAgent.mode).toBe("subagent") + }) + test("includes project and global .agents skills in builtin agent awareness", async () => { // given const projectAgentsSkill = { diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index fffa845e3..75bf062e8 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -99,10 +99,12 @@ export async function applyAgentConfig(params: { const rawPluginAgents = params.pluginComponents.agents; const pluginAgents = Object.fromEntries( - Object.entries(rawPluginAgents).map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]), + Object.entries(rawPluginAgents).map(([key, value]) => { + if (!value) return [key, value]; + const migrated = migrateAgentConfig(value as Record); + if (!migrated.mode) migrated.mode = "subagent"; + return [key, migrated]; + }), ); const configAgent = params.config.agent as AgentConfigRecord | undefined; @@ -219,10 +221,12 @@ export async function applyAgentConfig(params: { if (key in builtinAgents) return false; return true; }) - .map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]), + .map(([key, value]) => { + if (!value) return [key, value]; + const migrated = migrateAgentConfig(value as Record); + if (!migrated.mode) migrated.mode = "subagent"; + return [key, migrated]; + }), ) : {}; @@ -285,12 +289,23 @@ export async function applyAgentConfig(params: { protectedBuiltinAgentNames, ); + const defaultedConfigAgents = configAgent + ? Object.fromEntries( + Object.entries(configAgent).map(([key, value]) => { + if (!value) return [key, value]; + const migrated = migrateAgentConfig(value as Record); + if (!migrated.mode) migrated.mode = "subagent"; + return [key, migrated]; + }), + ) + : {}; + params.config.agent = { ...builtinAgents, ...filterDisabledAgents(filteredUserAgents), ...filterDisabledAgents(filteredProjectAgents), ...filterDisabledAgents(filteredPluginAgents), - ...configAgent, + ...defaultedConfigAgents, }; } diff --git a/src/plugin-handlers/agent-key-remapper.test.ts b/src/plugin-handlers/agent-key-remapper.test.ts index 81d41c69e..3b14781c6 100644 --- a/src/plugin-handlers/agent-key-remapper.test.ts +++ b/src/plugin-handlers/agent-key-remapper.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test" import { remapAgentKeysToDisplayNames } from "./agent-key-remapper" -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" describe("remapAgentKeysToDisplayNames", () => { it("remaps known agent keys to display names", () => { @@ -14,7 +14,7 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then known agents get display name keys only - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["oracle"]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() }) @@ -49,21 +49,21 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then all get display name keys - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() - expect(result[getAgentListDisplayName("hephaestus")]).toBeDefined() + expect(result[getAgentDisplayName("hephaestus")]).toBeDefined() expect(result["hephaestus"]).toBeUndefined() - expect(result[getAgentListDisplayName("prometheus")]).toBeDefined() + expect(result[getAgentDisplayName("prometheus")]).toBeDefined() expect(result["prometheus"]).toBeUndefined() - expect(result[getAgentListDisplayName("atlas")]).toBeDefined() + expect(result[getAgentDisplayName("atlas")]).toBeDefined() expect(result["atlas"]).toBeUndefined() - expect(result[getAgentListDisplayName("athena")]).toBeDefined() + expect(result[getAgentDisplayName("athena")]).toBeDefined() expect(result["athena"]).toBeUndefined() - expect(result[getAgentListDisplayName("metis")]).toBeDefined() + expect(result[getAgentDisplayName("metis")]).toBeDefined() expect(result["metis"]).toBeUndefined() - expect(result[getAgentListDisplayName("momus")]).toBeDefined() + expect(result[getAgentDisplayName("momus")]).toBeDefined() expect(result["momus"]).toBeUndefined() - expect(result[getAgentListDisplayName("sisyphus-junior")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus-junior")]).toBeDefined() expect(result["sisyphus-junior"]).toBeUndefined() }) @@ -77,8 +77,8 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then only display key is emitted - expect(Object.keys(result)).toEqual([getAgentListDisplayName("sisyphus")]) - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(Object.keys(result)).toEqual([getAgentDisplayName("sisyphus")]) + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() }) }) diff --git a/src/plugin-handlers/agent-key-remapper.ts b/src/plugin-handlers/agent-key-remapper.ts index 1becbcda9..54d422a4b 100644 --- a/src/plugin-handlers/agent-key-remapper.ts +++ b/src/plugin-handlers/agent-key-remapper.ts @@ -1,4 +1,4 @@ -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" export function remapAgentKeysToDisplayNames( agents: Record, @@ -6,7 +6,7 @@ export function remapAgentKeysToDisplayNames( const result: Record = {} for (const [key, value] of Object.entries(agents)) { - const displayName = getAgentListDisplayName(key) + const displayName = getAgentDisplayName(key) if (displayName && displayName !== key) { result[displayName] = value // Regression guard: do not also assign result[key]. diff --git a/src/plugin-handlers/agent-priority-order.test.ts b/src/plugin-handlers/agent-priority-order.test.ts index 2e48f0053..d28f6634a 100644 --- a/src/plugin-handlers/agent-priority-order.test.ts +++ b/src/plugin-handlers/agent-priority-order.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from "bun:test" import { reorderAgentsByPriority } from "./agent-priority-order" -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" describe("reorderAgentsByPriority", () => { test("moves core agents to canonical order and injects runtime order fields", () => { // given - const sisyphus = getAgentListDisplayName("sisyphus") - const hephaestus = getAgentListDisplayName("hephaestus") - const prometheus = getAgentListDisplayName("prometheus") - const atlas = getAgentListDisplayName("atlas") - const oracle = getAgentListDisplayName("oracle") + const sisyphus = getAgentDisplayName("sisyphus") + const hephaestus = getAgentDisplayName("hephaestus") + const prometheus = getAgentDisplayName("prometheus") + const atlas = getAgentDisplayName("atlas") + const oracle = getAgentDisplayName("oracle") const agents: Record = { [oracle]: { name: "oracle", mode: "subagent" }, @@ -59,8 +59,8 @@ describe("reorderAgentsByPriority", () => { test("leaves non-object agent configs untouched while still reordering keys", () => { // given - const sisyphus = getAgentListDisplayName("sisyphus") - const atlas = getAgentListDisplayName("atlas") + const sisyphus = getAgentDisplayName("sisyphus") + const atlas = getAgentDisplayName("atlas") const agents: Record = { [atlas]: "atlas-config", diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index f69b9a13b..c315ad76a 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -1,10 +1,10 @@ -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [ - { displayName: getAgentListDisplayName("sisyphus"), order: 1 }, - { displayName: getAgentListDisplayName("hephaestus"), order: 2 }, - { displayName: getAgentListDisplayName("prometheus"), order: 3 }, - { displayName: getAgentListDisplayName("atlas"), order: 4 }, + { displayName: getAgentDisplayName("sisyphus"), order: 1 }, + { displayName: getAgentDisplayName("hephaestus"), order: 2 }, + { displayName: getAgentDisplayName("prometheus"), order: 3 }, + { displayName: getAgentDisplayName("atlas"), order: 4 }, ]; function injectOrderField( diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index b5837c76b..7a2c80ad4 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,10 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { + getAgentDisplayName, + getAgentDisplayName, +} from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -97,7 +100,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("remaps Atlas command agents to the list display name used by runtime agent lookup", async () => { + test("normalizes Atlas command agents to the exported list key used by opencode command routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,6 +122,31 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + }); + + test("normalizes legacy display-name command agents to the exported list key", async () => { + // given + loadBuiltinCommandsSpy.mockReturnValue({ + "start-work": { + name: "start-work", + description: "(builtin) Start work", + template: "template", + agent: getAgentDisplayName("atlas"), + }, + }); + const config: Record = { command: {} }; + + // when + await applyCommandConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }); + + // then + const commandConfig = config.command as Record; + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 08b40d4d1..86fdcfe26 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,5 +1,8 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { + getAgentConfigKey, + getAgentDisplayName, +} from "../shared/agent-display-names"; import { loadUserCommands, loadProjectCommands, @@ -96,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentListDisplayName(cmd.agent); + cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 2257c45b9..3f1e58a88 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -4,7 +4,7 @@ import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" import type { CategoryConfig } from "../config/schema" import type { OhMyOpenCodeConfig } from "../config" -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName, getAgentDisplayName } from "../shared/agent-display-names" import * as agents from "../agents" import * as sisyphusJunior from "../agents/sisyphus-junior" @@ -246,10 +246,10 @@ describe("Plan agent demote behavior", () => { // #then const keys = Object.keys(config.agent as Record) const coreAgents = [ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), ] const ordered = keys.filter((key) => coreAgents.includes(key)) expect(ordered).toEqual(coreAgents) @@ -294,10 +294,10 @@ describe("Plan agent demote behavior", () => { reorderSpy.mock.calls.at(0)?.[0] as Record ) expect(assembledAgentKeys.slice(0, 4)).toEqual([ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), ]) }) @@ -336,7 +336,7 @@ describe("Plan agent demote behavior", () => { expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("subagent") expect(agents.plan.prompt).toBeUndefined() - expect(agents[getAgentListDisplayName("prometheus")]?.prompt).toBeDefined() + expect(agents[getAgentDisplayName("prometheus")]?.prompt).toBeDefined() }) test("plan agent remains unchanged when planner is disabled", async () => { @@ -370,7 +370,7 @@ describe("Plan agent demote behavior", () => { // #then - plan is not touched, prometheus is not created const agents = config.agent as Record - expect(agents[getAgentListDisplayName("prometheus")]).toBeUndefined() + expect(agents[getAgentDisplayName("prometheus")]).toBeUndefined() expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("primary") expect(agents.plan.prompt).toBe("original plan prompt") @@ -401,7 +401,7 @@ describe("Plan agent demote behavior", () => { // then const agents = config.agent as Record - const prometheusKey = getAgentListDisplayName("prometheus") + const prometheusKey = getAgentDisplayName("prometheus") expect(agents[prometheusKey]).toBeDefined() expect(agents[prometheusKey].mode).toBe("all") }) @@ -437,7 +437,7 @@ describe("Agent permission defaults", () => { // #then const agentConfig = config.agent as Record }> - const hephaestusKey = getAgentListDisplayName("hephaestus") + const hephaestusKey = getAgentDisplayName("hephaestus") expect(agentConfig[hephaestusKey]).toBeDefined() expect(agentConfig[hephaestusKey].permission?.task).toBe("allow") }) @@ -779,7 +779,7 @@ describe("Prometheus direct override priority over category", () => { // then - direct override's reasoningEffort wins const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].reasoningEffort).toBe("low") }) @@ -820,7 +820,7 @@ describe("Prometheus direct override priority over category", () => { // then - category's reasoningEffort is applied const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].reasoningEffort).toBe("high") }) @@ -862,7 +862,7 @@ describe("Prometheus direct override priority over category", () => { // then - direct temperature wins over category const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].temperature).toBe(0.1) }) @@ -898,7 +898,7 @@ describe("Prometheus direct override priority over category", () => { // #then - prompt_append is appended to base prompt, not overwriting it const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].prompt).toContain("Prometheus") expect(agents[pKey].prompt).toContain(customInstructions) @@ -1250,13 +1250,58 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) }) +describe("command agent routing coherence", () => { + test("keeps start-work aligned with the exported Atlas list key opencode matches exactly", async () => { + //#given + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ + sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, + atlas: { name: "atlas", prompt: "test", mode: "primary" }, + }) + ;(builtinCommands.loadBuiltinCommands as unknown as { + mockReturnValue: (value: Record) => void + }).mockReturnValue({ + "start-work": { + name: "start-work", + description: "(builtin) Start work", + template: "template", + agent: "atlas", + }, + }) + const pluginConfig = createPluginConfig({}) + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + const agentConfig = config.agent as Record + const commandConfig = config.command as Record + expect(Object.keys(agentConfig)).toContain(getAgentDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) + }) +}) + describe("per-agent todowrite/todoread deny when task_system enabled", () => { const AGENTS_WITH_TODO_DENY = new Set([ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), - getAgentListDisplayName("sisyphus-junior"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), + getAgentDisplayName("sisyphus-junior"), ]) test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { @@ -1336,10 +1381,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { expect(lastCall?.[11]).toBe(false) const agentResult = config.agent as Record }> - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() - expect(agentResult[getAgentListDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() }) test("does not deny todowrite/todoread when task_system is undefined", async () => { @@ -1375,8 +1420,8 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { expect(lastCall?.[11]).toBe(false) const agentResult = config.agent as Record }> - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() }) }) diff --git a/src/plugin-handlers/prometheus-agent-config-builder.test.ts b/src/plugin-handlers/prometheus-agent-config-builder.test.ts index e6440834a..e01403289 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.test.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test, spyOn, afterEach, beforeEach } from "bun:test"; +import { describe, expect, test, spyOn, afterEach, beforeEach, mock } from "bun:test"; + +// Isolate from other tests that mock.module the logger (CI cross-contamination fix) +mock.module("../shared/logger", () => ({ log: (..._args: unknown[]) => {} })) + import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import * as shared from "../shared"; import * as categoryResolver from "./category-config-resolver"; diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index dae34fda6..d698e9560 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -1,5 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; import { isTaskSystemEnabled } from "../shared"; type AgentWithPermission = { permission?: Record }; @@ -16,7 +16,7 @@ function getConfigQuestionPermission(): string | null { } function agentByKey(agentResult: Record, key: string): AgentWithPermission | undefined { - return (agentResult[getAgentListDisplayName(key)] ?? agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as + return (agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as | AgentWithPermission | undefined; } diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index 4dac3f7be..fea4752e2 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto" import { createPluginInterface } from "./plugin-interface" import { createAutoSlashCommandHook } from "./hooks/auto-slash-command" import { createStartWorkHook } from "./hooks/start-work" -import { getAgentListDisplayName } from "./shared/agent-display-names" import { readBoulderState } from "./features/boulder-state" import { _resetForTesting, diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index e7128140b..2d96f6065 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -9,7 +9,6 @@ import { createAutoSlashCommandHook } from "../hooks/auto-slash-command" import { createStartWorkHook } from "../hooks/start-work" import { readBoulderState } from "../features/boulder-state" import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state" -import { getAgentListDisplayName } from "../shared/agent-display-names" import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state" type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } @@ -94,6 +93,34 @@ describe("createChatMessageHandler - /start-work integration", () => { expect(getSessionAgent("test-session")).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) + + test("smoke: resolves quoted human-readable plan names through the full /start-work chat.message path", async () => { + // given + writeFileSync(join(testDir, ".sisyphus", "plans", "my-feature-plan.md"), "# Plan\n- [ ] Task 1") + updateSessionAgent("test-session", "prometheus") + const args = createMockHandlerArgs() + args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] }) + args.hooks.startWork = createStartWorkHook({ + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never) + const handler = createChatMessageHandler(args) + const input = createMockInput("prometheus") + const output: ChatMessageHandlerOutput = { + message: {}, + parts: [{ type: "text", text: "/start-work \"my feature plan\"" }], + } + + // when + await handler(input, output) + + // then + expect(output.message["agent"]).toBe("sisyphus") + expect(output.parts[0].text).toContain("") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + expect(output.parts[0].text).toContain("my-feature-plan") + expect(readBoulderState(testDir)?.plan_name).toBe("my-feature-plan") + }) }) describe("createChatMessageHandler - /ulw-loop raw slash fallback", () => { @@ -375,7 +402,10 @@ describe("createChatMessageHandler - TUI variant passthrough", () => { expect(getSessionModel("test-session")).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) }) - test("treats prefixed list-display agent names as explicit model overrides", async () => { + test("treats legacy ZWSP-prefixed agent names as explicit model overrides (GH-3259)", async () => { + // Users upgrading from v3.14.0-v3.16.0 may still have ZWSP-prefixed agent + // keys persisted in their session state. The handler must strip the + // prefix and resolve to the canonical display name. //#given setMainSession("test-session") setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" }) @@ -388,7 +418,7 @@ describe("createChatMessageHandler - TUI variant passthrough", () => { }, }) const handler = createChatMessageHandler(args) - const input = createMockInput(getAgentListDisplayName("prometheus")) + const input = createMockInput("\u200B\u200B\u200BPrometheus - Plan Builder") const output = createMockOutput() //#when diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index 5f17f36eb..f75c1a243 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -123,10 +123,10 @@ describe("createChatParamsHandler", () => { setSessionPromptParams("ses_chat_params_temperature", { temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) @@ -157,31 +157,29 @@ describe("createChatParamsHandler", () => { temperature: 0.4, topP: 0.7, topK: 1, + maxOutputTokens: 4096, options: { existing: true, reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) expect(getSessionPromptParams("ses_chat_params_temperature")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) - test("drops gpt-5.4 temperature and clamps maxTokens from bundled model capabilities", async () => { + test("drops gpt-5.4 temperature and clamps maxOutputTokens from bundled model capabilities", async () => { //#given setSessionPromptParams("ses_chat_params_temperature", { temperature: 0.7, - options: { - maxTokens: 200_000, - }, + maxOutputTokens: 200_000, }) const handler = createChatParamsHandler({ @@ -210,9 +208,8 @@ describe("createChatParamsHandler", () => { expect(output).toEqual({ topP: 1, topK: 1, - options: { - maxTokens: 128_000, - }, + maxOutputTokens: 128_000, + options: {}, }) }) diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index d69a14f8e..b28f6a420 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -18,6 +18,7 @@ export type ChatParamsOutput = { temperature?: number topP?: number topK?: number + maxOutputTokens?: number options: Record } @@ -99,6 +100,9 @@ export function createChatParamsHandler(args: { if (storedPromptParams.topP !== undefined) { output.topP = storedPromptParams.topP } + if (storedPromptParams.maxOutputTokens !== undefined) { + (output as Record).maxOutputTokens = storedPromptParams.maxOutputTokens + } if (storedPromptParams.options) { output.options = { ...output.options, @@ -124,7 +128,7 @@ export function createChatParamsHandler(args: { : undefined, temperature: typeof output.temperature === "number" ? output.temperature : undefined, topP: typeof output.topP === "number" ? output.topP : undefined, - maxTokens: typeof output.options.maxTokens === "number" ? output.options.maxTokens : undefined, + maxTokens: typeof output.maxOutputTokens === "number" ? output.maxOutputTokens : undefined, thinking: isRecord(output.options.thinking) ? output.options.thinking : undefined, }, capabilities, @@ -163,9 +167,9 @@ export function createChatParamsHandler(args: { if ("maxTokens" in compatibility) { if (compatibility.maxTokens !== undefined) { - output.options.maxTokens = compatibility.maxTokens + output.maxOutputTokens = compatibility.maxTokens } else { - delete output.options.maxTokens + delete output.maxOutputTokens } } diff --git a/src/plugin/event.model-fallback-2941.test.ts b/src/plugin/event.model-fallback-2941.test.ts new file mode 100644 index 000000000..46765a5d9 --- /dev/null +++ b/src/plugin/event.model-fallback-2941.test.ts @@ -0,0 +1,165 @@ +declare const require: (name: string) => any +const { afterEach, describe, expect, spyOn, test } = require("bun:test") + +import { createEventHandler } from "./event" +import { createChatMessageHandler } from "./chat-message" +import { _resetForTesting, setSessionAgent } from "../features/claude-code-session-state" +import { clearPendingModelFallback, createModelFallbackHook, setSessionFallbackChain } from "../hooks/model-fallback/hook" +import * as connectedProvidersCache from "../shared/connected-providers-cache" + +type EventInput = { event: { type: string; properties?: unknown } } +type EventHandlerArgs = Parameters[0] +type EventHandlerInput = Parameters>[0] +type ChatMessageHandlerArgs = Parameters[0] + +function asEventHandlerInput(input: EventInput): EventHandlerInput { + return input as unknown as EventHandlerInput +} + +function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] { + return ctx as unknown as EventHandlerArgs["ctx"] +} + +function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] { + return config as unknown as EventHandlerArgs["pluginConfig"] +} + +function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] { + return ctx as unknown as ChatMessageHandlerArgs["ctx"] +} + +function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] { + return config as unknown as ChatMessageHandlerArgs["pluginConfig"] +} + +function createEventHandlerManagers(): EventHandlerArgs["managers"] { + return { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + skillMcpManager: { + disconnectSession: async () => {}, + }, + } as unknown as EventHandlerArgs["managers"] +} + +function createEventHandlerHooks(modelFallback: ReturnType): EventHandlerArgs["hooks"] { + return { + modelFallback, + } as unknown as EventHandlerArgs["hooks"] +} + +function createChatMessageHandlerHooks(modelFallback: ReturnType): ChatMessageHandlerArgs["hooks"] { + return { + modelFallback, + stopContinuationGuard: null, + keywordDetector: null, + claudeCodeHooks: null, + autoSlashCommand: null, + startWork: null, + ralphLoop: null, + } as unknown as ChatMessageHandlerArgs["hooks"] +} + +let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined +let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined + +afterEach(() => { + readConnectedProvidersCacheSpy?.mockRestore() + readProviderModelsCacheSpy?.mockRestore() + readConnectedProvidersCacheSpy = undefined + readProviderModelsCacheSpy = undefined + _resetForTesting() +}) + +describe("createEventHandler - category runtime fallback suppression", () => { + test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => { + //#given + const sessionID = "ses_category_override_no_fallback" + const abortCalls: string[] = [] + const promptCalls: string[] = [] + + readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null) + + clearPendingModelFallback(sessionID) + setSessionAgent(sessionID, "sisyphus-junior") + setSessionFallbackChain(sessionID, undefined) + + const modelFallback = createModelFallbackHook() + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ + directory: "/tmp", + client: { + session: { + abort: async ({ path }: { path: { id: string } }) => { + abortCalls.push(path.id) + return {} + }, + prompt: async ({ path }: { path: { id: string } }) => { + promptCalls.push(path.id) + return {} + }, + }, + }, + }), + pluginConfig: asPluginConfig({}), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers(), + hooks: createEventHandlerHooks(modelFallback), + }) + + const chatMessageHandler = createChatMessageHandler({ + ctx: asChatMessageHandlerContext({ + client: { + tui: { + showToast: async () => ({}), + }, + }, + }), + pluginConfig: asChatPluginConfig({}), + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + }, + hooks: createChatMessageHandlerHooks(modelFallback), + }) + + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.error", + properties: { + sessionID, + error: { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-sonnet-4-6\"}}", + isRetryable: true, + }, + }, + }, + }, + })) + + const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + await chatMessageHandler( + { + sessionID, + agent: "sisyphus-junior", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" }, + }, + output, + ) + + //#then + expect(abortCalls).toEqual([]) + expect(promptCalls).toEqual([]) + expect(output.message["model"]).toBeUndefined() + }) +}) diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts index 4bfd2b4b3..4da2b5085 100644 --- a/src/plugin/hooks/create-core-hooks.ts +++ b/src/plugin/hooks/create-core-hooks.ts @@ -36,6 +36,7 @@ export function createCoreHooks(args: { pluginConfig, isHookEnabled: (name) => isHookEnabled(name as HookName), safeHookEnabled, + ralphLoop: session.ralphLoop, }) return { diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts index c57a959bb..7d107571b 100644 --- a/src/plugin/hooks/create-transform-hooks.ts +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -1,5 +1,6 @@ import type { OhMyOpenCodeConfig } from "../../config" import type { PluginContext } from "../types" +import type { RalphLoopHook } from "../../hooks/ralph-loop" import { createClaudeCodeHooksHook, @@ -26,8 +27,9 @@ export function createTransformHooks(args: { pluginConfig: OhMyOpenCodeConfig isHookEnabled: (hookName: string) => boolean safeHookEnabled?: boolean + ralphLoop?: RalphLoopHook | null }): TransformHooks { - const { ctx, pluginConfig, isHookEnabled } = args + const { ctx, pluginConfig, isHookEnabled, ralphLoop } = args const safeHookEnabled = args.safeHookEnabled ?? true const claudeCodeHooks = isHookEnabled("claude-code-hooks") @@ -49,7 +51,7 @@ export function createTransformHooks(args: { const keywordDetector = isHookEnabled("keyword-detector") ? safeCreateHook( "keyword-detector", - () => createKeywordDetectorHook(ctx, contextCollector), + () => createKeywordDetectorHook(ctx, contextCollector, ralphLoop ?? undefined), { enabled: safeHookEnabled }, ) : null diff --git a/src/shared/agent-config-integration.test.ts b/src/shared/agent-config-integration.test.ts index 1afc2f033..6e4726a36 100644 --- a/src/shared/agent-config-integration.test.ts +++ b/src/shared/agent-config-integration.test.ts @@ -10,9 +10,9 @@ describe("Agent Config Integration", () => { const oldConfig = { Sisyphus: { model: "anthropic/claude-opus-4-6" }, Atlas: { model: "anthropic/claude-opus-4-6" }, - "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, - "Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-6" }, - "Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-6" }, + "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" }, + "Metis - Plan Consultant": { model: "anthropic/claude-sonnet-4-6" }, + "Momus - Plan Critic": { model: "anthropic/claude-sonnet-4-6" }, } // when - migration is applied @@ -28,9 +28,9 @@ describe("Agent Config Integration", () => { // then - old keys are removed expect(result.migrated).not.toHaveProperty("Sisyphus") expect(result.migrated).not.toHaveProperty("Atlas") - expect(result.migrated).not.toHaveProperty("Prometheus (Planner)") - expect(result.migrated).not.toHaveProperty("Metis (Plan Consultant)") - expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)") + expect(result.migrated).not.toHaveProperty("Prometheus - Plan Builder") + expect(result.migrated).not.toHaveProperty("Metis - Plan Consultant") + expect(result.migrated).not.toHaveProperty("Momus - Plan Critic") // then - values are preserved expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" }) @@ -64,7 +64,7 @@ describe("Agent Config Integration", () => { const mixedConfig = { Sisyphus: { model: "anthropic/claude-opus-4-6" }, oracle: { model: "openai/gpt-5.4" }, - "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, + "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" }, librarian: { model: "opencode/big-pickle" }, } @@ -174,7 +174,7 @@ describe("Agent Config Integration", () => { // given - old format config const oldConfig = { Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 }, - "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, + "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" }, } // when - config is migrated diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 050e4cfde..b77a5e1ff 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -150,6 +150,14 @@ describe("getAgentConfigKey", () => { expect(getAgentConfigKey("atlas - plan executor")).toBe("atlas") }) + it("resolves legacy parenthesized display names", () => { + // given legacy parenthesized display name from old configs/sessions + // when getAgentConfigKey called + // then resolves to canonical config key + expect(getAgentConfigKey("Sisyphus (Ultraworker)")).toBe("sisyphus") + expect(getAgentConfigKey("Atlas (Plan Executor)")).toBe("atlas") + }) + it("passes through lowercase config keys unchanged", () => { // given lowercase config key "prometheus" // when getAgentConfigKey called @@ -175,36 +183,52 @@ describe("getAgentConfigKey", () => { expect(getAgentConfigKey("Sisyphus-Junior")).toBe("sisyphus-junior") }) - it("resolves atlas even when the UI ordering prefix is present", () => { - expect(getAgentConfigKey(getAgentListDisplayName("atlas"))).toBe("atlas") + it("resolves atlas even when a legacy ZWSP sort prefix is present on the stored key", () => { + // Users who installed v3.14.0 through v3.16.0 may have ZWSP-prefixed agent + // names baked into their config.agent keys. The resolver must still find + // the canonical config key after strip. + expect(getAgentConfigKey("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("atlas") }) }) -describe("getAgentListDisplayName", () => { - it("applies invisible stable-sort prefixes to the core agent list", () => { - expect(getAgentListDisplayName("sisyphus")).toBe("\u200BSisyphus - Ultraworker") - expect(getAgentListDisplayName("hephaestus")).toBe("\u200B\u200BHephaestus - Deep Agent") - expect(getAgentListDisplayName("prometheus")).toBe("\u200B\u200B\u200BPrometheus - Plan Builder") - expect(getAgentListDisplayName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") +describe("getAgentListDisplayName (deprecated alias, GH-3259)", () => { + it("returns plain display names without the legacy ZWSP sort prefix", () => { + // ZWSP prefixes were removed in #3242/#3259. This alias is retained for + // external callers that may still import it, but it now behaves + // identically to getAgentDisplayName. + expect(getAgentListDisplayName("sisyphus")).toBe("Sisyphus - Ultraworker") + expect(getAgentListDisplayName("hephaestus")).toBe("Hephaestus - Deep Agent") + expect(getAgentListDisplayName("prometheus")).toBe("Prometheus - Plan Builder") + expect(getAgentListDisplayName("atlas")).toBe("Atlas - Plan Executor") }) - it("keeps non-core agents unprefixed for list display", () => { + it("matches getAgentDisplayName for unknown agents", () => { expect(getAgentListDisplayName("oracle")).toBe("oracle") }) + + it("contains no zero-width characters in any core agent output (GH-3259)", () => { + const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"] + for (const agent of coreAgents) { + const result = getAgentListDisplayName(agent) + expect(result).not.toMatch(/[\u200B\u200C\u200D\uFEFF]/) + } + }) }) describe("normalizeAgentForPrompt", () => { - it("strips core UI ordering prefixes back to canonical display names", () => { - expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus - Ultraworker") - expect(normalizeAgentForPrompt(getAgentListDisplayName("hephaestus"))).toBe("Hephaestus - Deep Agent") - expect(normalizeAgentForPrompt(getAgentListDisplayName("prometheus"))).toBe("Prometheus - Plan Builder") - expect(normalizeAgentForPrompt(getAgentListDisplayName("atlas"))).toBe("Atlas - Plan Executor") + it("strips legacy ZWSP sort prefixes from stored agent keys back to canonical display names", () => { + // Configs from v3.14.0-v3.16.0 may persist ZWSP-prefixed keys. The + // normalizer must restore the canonical name on read. + expect(normalizeAgentForPrompt("\u200BSisyphus - Ultraworker")).toBe("Sisyphus - Ultraworker") + expect(normalizeAgentForPrompt("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent") + expect(normalizeAgentForPrompt("\u200B\u200B\u200BPrometheus - Plan Builder")).toBe("Prometheus - Plan Builder") + expect(normalizeAgentForPrompt("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("Atlas - Plan Executor") }) }) describe("normalizeAgentForPromptKey", () => { it("converts built-in display names to config keys", () => { - expect(normalizeAgentForPromptKey("Sisyphus - Ultraworker")).toBe("sisyphus") + expect(normalizeAgentForPromptKey("Sisyphus (Ultraworker)")).toBe("sisyphus") }) it("preserves custom agents", () => { @@ -236,4 +260,15 @@ describe("AGENT_DISPLAY_NAMES", () => { // then contains all expected mappings expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings) }) + + it("all display names must be HTTP-header-safe (no parentheses)", () => { + // given all agent display names + const httpHeaderUnsafe = /[()]/ + + // when checking each display name + for (const [key, displayName] of Object.entries(AGENT_DISPLAY_NAMES)) { + // then none should contain parentheses + expect(httpHeaderUnsafe.test(displayName)).toBe(false) + } + }) }) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index d42493fc6..2ccb545ad 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -1,7 +1,13 @@ /** * Agent config keys to display names mapping. * Config keys are lowercase (e.g., "sisyphus", "atlas"). - * Display names include suffixes for UI/logs (e.g., "Sisyphus (Ultraworker)"). + * Display names include suffixes for UI/logs (e.g., "Sisyphus - Ultraworker"). + * + * IMPORTANT: Display names MUST NOT contain parentheses or other characters + * that are invalid in HTTP header values per RFC 7230. OpenCode passes the + * agent name in the `x-opencode-agent-name` header, and parentheses cause + * header validation failures that prevent agents from appearing in the UI + * type selector dropdown. Use ` - ` (space-dash-space) instead of `(...)`. */ export const AGENT_DISPLAY_NAMES: Record = { sisyphus: "Sisyphus - Ultraworker", @@ -20,14 +26,24 @@ export const AGENT_DISPLAY_NAMES: Record = { "council-member": "council-member", } -const AGENT_LIST_SORT_PREFIXES: Record = { - sisyphus: "\u200B", - hephaestus: "\u200B\u200B", - prometheus: "\u200B\u200B\u200B", - atlas: "\u200B\u200B\u200B\u200B", -} - -function stripAgentListSortPrefix(agentName: string): string { +/** + * Strip the legacy zero-width-space sort prefix from an agent name. + * + * v3.14.0 through v3.16.0 prefixed the four core agents (Sisyphus, + * Hephaestus, Prometheus, Atlas) with U+200B Zero Width Space characters + * so they would sort ahead of user agents in the Tab cycle. Some terminal + * emulators (Ghostty, certain Windows Terminal builds) render ZWSP as a + * visible box or extra space, breaking the status bar layout (#3259), and + * the prefixes also leaked through the plugin API and broke prompt_async + * consumers (#3238). + * + * The prefixes are no longer injected anywhere (#3242 removed all call + * sites and #3259 removed the constant table). This helper remains so + * existing user configs that still have the ZWSP baked into their + * `config.agent` keys from an older install continue to resolve + * correctly after upgrading. + */ +export function stripAgentListSortPrefix(agentName: string): string { return agentName.replace(/^\u200B+/, "") } @@ -51,25 +67,50 @@ export function getAgentDisplayName(configKey: string): string { return configKey } +/** + * @deprecated Use {@link getAgentDisplayName} directly. + * + * Historically this returned the display name with a ZWSP sort prefix + * prepended so core agents would sort ahead of user agents in the Tab + * cycle. The ZWSP prefixes caused visible rendering artifacts in some + * terminals (#3259) and leaked into the plugin API surface (#3238), so + * they were removed in #3242/#3259. This function is now a thin alias + * over {@link getAgentDisplayName} that exists only for external + * callers that may still import it. Sort ordering is now handled by + * the `order` field injection in `reorderAgentsByPriority()` plus the + * core-first insertion order in the same helper. + */ export function getAgentListDisplayName(configKey: string): string { - const displayName = getAgentDisplayName(configKey) - const prefix = AGENT_LIST_SORT_PREFIXES[configKey.toLowerCase()] - - return prefix ? `${prefix}${displayName}` : displayName + return getAgentDisplayName(configKey) } const REVERSE_DISPLAY_NAMES: Record = Object.fromEntries( Object.entries(AGENT_DISPLAY_NAMES).map(([key, displayName]) => [displayName.toLowerCase(), key]), ) +// Legacy parenthesized display names for backward compatibility. +// Old configs/sessions may reference these names; resolve them to config keys. +const LEGACY_DISPLAY_NAMES: Record = { + "sisyphus (ultraworker)": "sisyphus", + "hephaestus (deep agent)": "hephaestus", + "prometheus (plan builder)": "prometheus", + "atlas (plan executor)": "atlas", + "metis (plan consultant)": "metis", + "momus (plan critic)": "momus", + "athena (council)": "athena", + "athena-junior (council)": "athena-junior", +} + /** * Resolve an agent name (display name or config key) to its lowercase config key. - * "Atlas (Plan Executor)" → "atlas", "atlas" → "atlas", "unknown" → "unknown" + * "Atlas - Plan Executor" -> "atlas", "Atlas (Plan Executor)" -> "atlas", "atlas" -> "atlas" */ export function getAgentConfigKey(agentName: string): string { const lower = stripAgentListSortPrefix(agentName).toLowerCase() const reversed = REVERSE_DISPLAY_NAMES[lower] if (reversed !== undefined) return reversed + const legacy = LEGACY_DISPLAY_NAMES[lower] + if (legacy !== undefined) return legacy if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower return lower } @@ -95,6 +136,10 @@ export function normalizeAgentForPrompt(agentName: string | undefined): string | if (reversed !== undefined) { return AGENT_DISPLAY_NAMES[reversed] ?? trimmed } + const legacy = LEGACY_DISPLAY_NAMES[lower] + if (legacy !== undefined) { + return AGENT_DISPLAY_NAMES[legacy] ?? trimmed + } if (AGENT_DISPLAY_NAMES[lower] !== undefined) { return AGENT_DISPLAY_NAMES[lower] } @@ -117,6 +162,10 @@ export function normalizeAgentForPromptKey(agentName: string | undefined): strin if (reversed !== undefined) { return reversed } + const legacy = LEGACY_DISPLAY_NAMES[lower] + if (legacy !== undefined) { + return legacy + } if (AGENT_DISPLAY_NAMES[lower] !== undefined) { return lower } diff --git a/src/shared/compaction-marker.ts b/src/shared/compaction-marker.ts new file mode 100644 index 000000000..6af43e774 --- /dev/null +++ b/src/shared/compaction-marker.ts @@ -0,0 +1,57 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "./opencode-storage-paths" + +type CompactionPartLike = { + type?: unknown +} + +type CompactionMessageLike = { + agent?: unknown + info?: { + agent?: unknown + } + parts?: unknown +} + +function isCompactionPart(part: unknown): boolean { + return typeof part === "object" && part !== null && (part as CompactionPartLike).type === "compaction" +} + +export function isCompactionAgent(agent: unknown): boolean { + return typeof agent === "string" && agent.trim().toLowerCase() === "compaction" +} + +export function hasCompactionPart(parts: unknown): boolean { + return Array.isArray(parts) && parts.some((part) => isCompactionPart(part)) +} + +export function isCompactionMessage(message: CompactionMessageLike): boolean { + return isCompactionAgent(message.info?.agent ?? message.agent) || hasCompactionPart(message.parts) +} + +export function hasCompactionPartInStorage(messageID: string | undefined): boolean { + if (!messageID) { + return false + } + + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) { + return false + } + + try { + return readdirSync(partDir) + .filter((fileName) => fileName.endsWith(".json")) + .some((fileName) => { + try { + const content = readFileSync(join(partDir, fileName), "utf-8") + return isCompactionPart(JSON.parse(content)) + } catch { + return false + } + }) + } catch { + return false + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index fff73f3fb..485926bfd 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -68,6 +68,7 @@ export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" export * from "./session-directory-resolver" export * from "./prompt-tools" +export * from "./compaction-marker" export * from "./internal-initiator-marker" export * from "./plugin-command-discovery" export { SessionCategoryRegistry } from "./session-category-registry" diff --git a/src/shared/internal-initiator-marker.test.ts b/src/shared/internal-initiator-marker.test.ts new file mode 100644 index 000000000..cc1035dd8 --- /dev/null +++ b/src/shared/internal-initiator-marker.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { + OMO_INTERNAL_INITIATOR_MARKER, + createInternalAgentTextPart, + stripInternalInitiatorMarkers, +} from "./internal-initiator-marker" + +describe("internal-initiator-marker", () => { + describe("createInternalAgentTextPart", () => { + test("#given clean text #when creating an internal agent text part #then appends exactly one marker", () => { + // given + const text = "Hello world" + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.type).toBe("text") + expect(part.text).toBe(`Hello world\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + + test("#given text already ending with the marker #when creating a text part #then does not duplicate the marker", () => { + // given + const text = `Already marked\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + expect(part.text).toBe(`Already marked\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + + test("#given text containing multiple embedded markers #when creating a text part #then collapses to a single trailing marker", () => { + // given + const text = `First\n${OMO_INTERNAL_INITIATOR_MARKER}\nSecond\n${OMO_INTERNAL_INITIATOR_MARKER}\nThird\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + expect(part.text.endsWith(OMO_INTERNAL_INITIATOR_MARKER)).toBe(true) + }) + + test("#given text with embedded markers between content #when creating a text part #then strips embedded markers and keeps content", () => { + // given + const text = `Line one\n${OMO_INTERNAL_INITIATOR_MARKER}\nLine two\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.text).toContain("Line one") + expect(part.text).toContain("Line two") + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + }) + + test("#given empty text #when creating a text part #then still appends a single marker", () => { + // given + const text = "" + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.text).toBe(`\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + }) + + describe("stripInternalInitiatorMarkers", () => { + test("#given text with no markers #when stripping #then returns text trimmed at the end", () => { + // given + const text = "No markers here" + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("No markers here") + }) + + test("#given text with one trailing marker #when stripping #then removes the marker", () => { + // given + const text = `Content\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("Content") + }) + + test("#given text with multiple stacked markers #when stripping #then removes all of them", () => { + // given + const text = `Content\n${OMO_INTERNAL_INITIATOR_MARKER}\n${OMO_INTERNAL_INITIATOR_MARKER}\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("Content") + }) + + test("#given text with markers on consecutive lines without separators #when stripping #then removes all markers", () => { + // given + const text = `${OMO_INTERNAL_INITIATOR_MARKER}${OMO_INTERNAL_INITIATOR_MARKER}${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("") + }) + }) +}) diff --git a/src/shared/internal-initiator-marker.ts b/src/shared/internal-initiator-marker.ts index 3e19c5819..7e810a15e 100644 --- a/src/shared/internal-initiator-marker.ts +++ b/src/shared/internal-initiator-marker.ts @@ -1,11 +1,18 @@ export const OMO_INTERNAL_INITIATOR_MARKER = "" +const INTERNAL_INITIATOR_MARKER_PATTERN = /\n*\s*/g + +export function stripInternalInitiatorMarkers(text: string): string { + return text.replace(INTERNAL_INITIATOR_MARKER_PATTERN, "").trimEnd() +} + export function createInternalAgentTextPart(text: string): { type: "text" text: string } { + const cleanText = stripInternalInitiatorMarkers(text) return { type: "text", - text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`, + text: `${cleanText}\n${OMO_INTERNAL_INITIATOR_MARKER}`, } } diff --git a/src/shared/jsonc-parser.test.ts b/src/shared/jsonc-parser.test.ts index 26c0914e2..279db1fc5 100644 --- a/src/shared/jsonc-parser.test.ts +++ b/src/shared/jsonc-parser.test.ts @@ -139,6 +139,33 @@ describe("parseJsonc", () => { // then expect(() => parseJsonc(invalid)).toThrow() }) + + test("parses content with UTF-8 BOM prefix", () => { + // given + const jsonc = `\uFEFF{"key": "value"}` + + // when + const result = parseJsonc<{ key: string }>(jsonc) + + // then + expect(result.key).toBe("value") + }) + + test("parses commented JSONC with UTF-8 BOM prefix", () => { + // given + const jsonc = `\uFEFF{ + // Windows-saved file with BOM + "$schema": "https://opencode.ai/config.json", + "plugin": ["oh-my-openagent@3.15.3"], + }` + + // when + const result = parseJsonc<{ $schema: string; plugin: string[] }>(jsonc) + + // then + expect(result.$schema).toBe("https://opencode.ai/config.json") + expect(result.plugin).toEqual(["oh-my-openagent@3.15.3"]) + }) }) describe("parseJsoncSafe", () => { @@ -166,6 +193,19 @@ describe("parseJsoncSafe", () => { expect(result.data).toBeNull() expect(result.errors.length).toBeGreaterThan(0) }) + + test("returns data when content has UTF-8 BOM prefix", () => { + // given + const jsonc = `\uFEFF{"key": "value"}` + + // when + const result = parseJsoncSafe<{ key: string }>(jsonc) + + // then + expect(result.errors).toHaveLength(0) + expect(result.data).not.toBeNull() + expect(result.data?.key).toBe("value") + }) }) describe("readJsoncFile", () => { @@ -215,6 +255,28 @@ describe("readJsoncFile", () => { rmSync(testDir, { recursive: true, force: true }) }) + + test("reads JSONC file written with UTF-8 BOM (Windows scenario)", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const bomBytes = Buffer.from([0xef, 0xbb, 0xbf]) + const jsonBytes = Buffer.from(`{ + // Created on Windows with BOM + "$schema": "https://opencode.ai/config.json", + "plugin": ["oh-my-openagent@3.15.3"] + }`) + writeFileSync(testFile, Buffer.concat([bomBytes, jsonBytes])) + + // when + const result = readJsoncFile<{ $schema: string; plugin: string[] }>(testFile) + + // then + expect(result).not.toBeNull() + expect(result?.$schema).toBe("https://opencode.ai/config.json") + expect(result?.plugin).toEqual(["oh-my-openagent@3.15.3"]) + + rmSync(testDir, { recursive: true, force: true }) + }) }) describe("detectConfigFile", () => { diff --git a/src/shared/jsonc-parser.ts b/src/shared/jsonc-parser.ts index 66c886310..da1e0d98c 100644 --- a/src/shared/jsonc-parser.ts +++ b/src/shared/jsonc-parser.ts @@ -9,9 +9,16 @@ export interface JsoncParseResult { errors: Array<{ message: string; offset: number; length: number }> } +function stripBom(content: string): string { + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content +} + export function parseJsonc(content: string): T { + // Strip UTF-8 BOM if present (Windows UTF-8 with BOM files) + content = content.replace(/^\uFEFF/, "") + const errors: ParseError[] = [] - const result = parse(content, errors, { + const result = parse(stripBom(content), errors, { allowTrailingComma: true, disallowComments: false, }) as T @@ -28,7 +35,7 @@ export function parseJsonc(content: string): T { export function parseJsoncSafe(content: string): JsoncParseResult { const errors: ParseError[] = [] - const data = parse(content, errors, { + const data = parse(stripBom(content), errors, { allowTrailingComma: true, disallowComments: false, }) as T | null diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index 5b11aa8c3..980fa50f2 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -148,36 +148,36 @@ describe("migrateAgentNames", () => { }) test("migrates Prometheus variants to lowercase", () => { - // given agents config with "Prometheus (Planner)" key + // given agents config with "Prometheus - Plan Builder" key // when migrateAgentNames called // then key becomes "prometheus" - const agents = { "Prometheus (Planner)": { model: "test" } } + const agents = { "Prometheus - Plan Builder": { model: "test" } } const { migrated, changed } = migrateAgentNames(agents) expect(changed).toBe(true) expect(migrated["prometheus"]).toEqual({ model: "test" }) - expect(migrated["Prometheus (Planner)"]).toBeUndefined() + expect(migrated["Prometheus - Plan Builder"]).toBeUndefined() }) test("migrates Metis variants to lowercase", () => { - // given agents config with "Metis (Plan Consultant)" key + // given agents config with "Metis - Plan Consultant" key // when migrateAgentNames called // then key becomes "metis" - const agents = { "Metis (Plan Consultant)": { model: "test" } } + const agents = { "Metis - Plan Consultant": { model: "test" } } const { migrated, changed } = migrateAgentNames(agents) expect(changed).toBe(true) expect(migrated["metis"]).toEqual({ model: "test" }) - expect(migrated["Metis (Plan Consultant)"]).toBeUndefined() + expect(migrated["Metis - Plan Consultant"]).toBeUndefined() }) test("migrates Momus variants to lowercase", () => { - // given agents config with "Momus (Plan Reviewer)" key + // given agents config with "Momus - Plan Critic" key // when migrateAgentNames called // then key becomes "momus" - const agents = { "Momus (Plan Reviewer)": { model: "test" } } + const agents = { "Momus - Plan Critic": { model: "test" } } const { migrated, changed } = migrateAgentNames(agents) expect(changed).toBe(true) expect(migrated["momus"]).toEqual({ model: "test" }) - expect(migrated["Momus (Plan Reviewer)"]).toBeUndefined() + expect(migrated["Momus - Plan Critic"]).toBeUndefined() }) test("migrates Sisyphus-Junior to lowercase", () => { @@ -321,6 +321,18 @@ describe("migrateHookNames", () => { describe("migrateConfigFile", () => { const testConfigPath = "/tmp/nonexistent-path-for-test.json" + // Tests in this block share a single config path and do not write a real + // config file, but migrateConfigFile now persists migration tracking to a + // sidecar next to the config (#3263). Clear the sidecar between tests so + // state from an earlier test does not bleed into the next one. + afterEach(() => { + try { + fs.unlinkSync(`${testConfigPath}.migrations.json`) + } catch { + // ignore — sidecar may not exist + } + }) + test("migrates experimental.hashline_edit to top-level hashline_edit", () => { // given: Config with legacy experimental.hashline_edit const rawConfig: Record = { @@ -790,8 +802,8 @@ describe("migrateConfigFile _migrations tracking", () => { fs.rmSync(tmpDir, { recursive: true }) }) - test("preserves existing _migrations and appends new ones", () => { - // given: Config with existing migration history and a new migratable model + test("migrates legacy in-config _migrations into the sidecar and appends new migrations (#3263)", () => { + // given: Config with an existing legacy in-config _migrations history and a new migratable model const tmpDir = fs.mkdtempSync("/tmp/migration-test-") const configPath = `${tmpDir}/oh-my-opencode.json` const rawConfig: Record = { @@ -804,12 +816,17 @@ describe("migrateConfigFile _migrations tracking", () => { // when: Migrate config file const result = migrateConfigFile(configPath, rawConfig) - // then: New migration appended, old one preserved + // then: The config body has _migrations stripped. The full history + // (legacy + new) is written to the sidecar file exactly once. expect(result).toBe(true) - expect(rawConfig._migrations).toEqual([ + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).prometheus.model).toBe("anthropic/claude-opus-4-6") + + const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", - ]) + ])) // cleanup fs.rmSync(tmpDir, { recursive: true }) @@ -1263,7 +1280,7 @@ describe("migrateModelVersions with applied migrations", () => { }) }) -describe("migrateConfigFile with _migrations tracking", () => { +describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => { const cleanupPaths: string[] = [] afterEach(() => { @@ -1276,72 +1293,180 @@ describe("migrateConfigFile with _migrations tracking", () => { cleanupPaths.length = 0 }) - test("records new migrations in _migrations field", () => { - // given: Config with old model, no _migrations field - const testConfigPath = "/tmp/test-config-migrations-1.json" + function tempConfigPath(label: string): string { + const workdir = fs.mkdtempSync(`/tmp/omo-migration-${label}-`) + cleanupPaths.push(workdir) + return path.join(workdir, "oh-my-openagent.json") + } + + function sidecarPath(configPath: string): string { + return `${configPath}.migrations.json` + } + + test("does not emit migration history when no migration applies", () => { + // given: Config with a model that does not appear in MODEL_VERSION_MAP + const testConfigPath = tempConfigPath("no-op") const rawConfig: Record = { agents: { sisyphus: { model: "openai/gpt-5.4-codex" }, }, } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: gpt-5.4-codex should not create migration history expect(needsWrite).toBe(false) expect(rawConfig._migrations).toBeUndefined() expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") + expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false) }) - test("skips re-applying already-recorded migrations", () => { - // given: Config with old model but migration already in _migrations - const testConfigPath = "/tmp/test-config-migrations-2.json" + test("writes applied migrations to sidecar instead of leaving them on the config", () => { + // given: Config that needs a real model migration and has no prior history + const testConfigPath = tempConfigPath("sidecar-write") const rawConfig: Record = { agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, - }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], - } - fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - - // when: Migrate config file - const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - - // then: Should not migrate (user reverted) - expect(needsWrite).toBe(false) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") - expect(rawConfig._migrations).toEqual(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"]) - }) - - test("preserves existing _migrations and appends new ones", () => { - // given: Config with multiple old models, partial migration history - const testConfigPath = "/tmp/test-config-migrations-3.json" - const rawConfig: Record = { - agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, oracle: { model: "anthropic/claude-opus-4-5" }, }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: Should skip sisyphus, migrate oracle, append to _migrations expect(needsWrite).toBe(true) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-6") - expect(rawConfig._migrations).toEqual([ - "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", ]) }) + test("skips re-applying a migration that is recorded in the sidecar even if the user edited _migrations away", () => { + // This is the core #3263 regression: a user auto-migrated from + // gpt-5.3-codex to gpt-5.4, reverted to gpt-5.3-codex by hand, and + // deleted _migrations in the process. Without the sidecar their + // revert was clobbered on every startup. + const testConfigPath = tempConfigPath("sidecar-revert") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(false) + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + expect(rawConfig._migrations).toBeUndefined() + }) + + test("mirrors legacy in-config _migrations into the sidecar and then strips the field", () => { + // BC path: configs written by older OMO versions still carry the + // legacy _migrations field in the JSON body. On the next startup we + // must copy that history into the new sidecar and remove the field + // from the config so the migration tracking lives in exactly one + // place from then on. + const testConfigPath = tempConfigPath("bc-mirror") + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + _migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite is true because we rewrote the config to drop _migrations + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("unions sidecar and legacy _migrations entries, deduplicating", () => { + // Defensive case: a config written by two different OMO versions + // could end up with an entry in _migrations that is also in the + // sidecar. The merged set should be deduplicated and the config + // should not be re-migrated. + const testConfigPath = tempConfigPath("sidecar-union") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "anthropic/claude-opus-4-5" }, + }, + _migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite because the legacy _migrations field was stripped + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + // The reverted opus-4-5 value must be preserved + expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-5") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("appends new migrations to the sidecar when partial history exists", () => { + // Scenario: sidecar already has one migration, a second model still + // needs to be migrated. The new migration should be recorded and the + // already-applied one preserved. + const testConfigPath = tempConfigPath("sidecar-append") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + codex: { model: "openai/gpt-5.3-codex" }, + claude: { model: "anthropic/claude-opus-4-5" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(true) + // codex was reverted, must stay + expect((rawConfig.agents as Record>).codex.model).toBe("openai/gpt-5.3-codex") + // claude migrates + expect((rawConfig.agents as Record>).claude.model).toBe("anthropic/claude-opus-4-6") + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ])) + }) }) diff --git a/src/shared/migration/agent-names.ts b/src/shared/migration/agent-names.ts index 3321b0b84..67b9e1dbe 100644 --- a/src/shared/migration/agent-names.ts +++ b/src/shared/migration/agent-names.ts @@ -10,7 +10,7 @@ export const AGENT_NAME_MAP: Record = { "omo-plan": "prometheus", "Planner-Sisyphus": "prometheus", "planner-sisyphus": "prometheus", - "Prometheus (Planner)": "prometheus", + "Prometheus - Plan Builder": "prometheus", prometheus: "prometheus", // Atlas variants → "atlas" @@ -20,11 +20,11 @@ export const AGENT_NAME_MAP: Record = { // Metis variants → "metis" "plan-consultant": "metis", - "Metis (Plan Consultant)": "metis", + "Metis - Plan Consultant": "metis", metis: "metis", // Momus variants → "momus" - "Momus (Plan Reviewer)": "momus", + "Momus - Plan Critic": "momus", momus: "momus", // Sisyphus-Junior → "sisyphus-junior" @@ -45,9 +45,9 @@ export const BUILTIN_AGENT_NAMES = new Set([ "librarian", "explore", "multimodal-looker", - "metis", // was "Metis (Plan Consultant)" - "momus", // was "Momus (Plan Reviewer)" - "prometheus", // was "Prometheus (Planner)" + "metis", // was "Metis - Plan Consultant" + "momus", // was "Momus - Plan Critic" + "prometheus", // was "Prometheus - Plan Builder" "atlas", // was "Atlas" "build", ]) diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 58a4b4b33..894bd2dcc 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -4,6 +4,7 @@ import { writeFileAtomically } from "../write-file-atomically" import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names" import { migrateHookNames } from "./hook-names" import { migrateModelVersions } from "./model-versions" +import { readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" export function migrateConfigFile( configPath: string, @@ -12,10 +13,22 @@ export function migrateConfigFile( const copy = structuredClone(rawConfig) let needsWrite = false - // Load previously applied migrations - const existingMigrations = Array.isArray(copy._migrations) + // Load previously applied migrations from BOTH the legacy in-config + // `_migrations` field AND the external sidecar file. The sidecar is the + // new source of truth because users were editing the config file to + // revert auto-migrated values and accidentally dropping the `_migrations` + // field in the process, which produced an infinite migration loop on + // every startup (#3263). Reading from both sources keeps old configs + // that still carry `_migrations` working without a forced reset. + const sidecarMigrations = readAppliedMigrations(configPath) + const inConfigMigrations = Array.isArray(copy._migrations) ? new Set(copy._migrations as string[]) : new Set() + const existingMigrations = new Set([ + ...sidecarMigrations, + ...inConfigMigrations, + ]) + const hadLegacyInConfigMigrations = inConfigMigrations.size > 0 const allNewMigrations: string[] = [] if (copy.agents && typeof copy.agents === "object") { @@ -54,13 +67,30 @@ export function migrateConfigFile( allNewMigrations.push(...newMigrations) } - // Record newly applied migrations - if (allNewMigrations.length > 0) { - const updatedMigrations = Array.from(existingMigrations) - updatedMigrations.push(...allNewMigrations) - copy._migrations = updatedMigrations + // Record newly applied migrations. We persist the full set (existing + + // new) to the external sidecar file and strip the legacy `_migrations` + // field from the config body on its way out, so users stop having to + // think about a field that never should have been in their config in + // the first place. The in-memory `rawConfig` never re-exposes + // `_migrations` to downstream schema validation. + const newMigrationsToRecord = allNewMigrations.filter(mKey => !existingMigrations.has(mKey)) + if (newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations) { + const fullMigrationSet = new Set([ + ...existingMigrations, + ...newMigrationsToRecord, + ]) + writeAppliedMigrations(configPath, fullMigrationSet) + } + if (newMigrationsToRecord.length > 0) { needsWrite = true } + if (hadLegacyInConfigMigrations) { + // Migrating state out of the config body is itself a config write. + needsWrite = true + } + if ("_migrations" in copy) { + delete copy._migrations + } if (copy.omo_agent) { copy.sisyphus_agent = copy.omo_agent diff --git a/src/shared/migration/migrations-sidecar.test.ts b/src/shared/migration/migrations-sidecar.test.ts new file mode 100644 index 000000000..5809bde94 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { getSidecarPath, readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" + +describe("migrations sidecar", () => { + let workdir: string + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "omo-migrations-sidecar-")) + }) + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe("getSidecarPath", () => { + test("appends .migrations.json to the config path", () => { + expect(getSidecarPath("/home/user/.config/opencode/oh-my-openagent.json")).toBe( + "/home/user/.config/opencode/oh-my-openagent.json.migrations.json", + ) + }) + + test("works for jsonc configs too", () => { + expect(getSidecarPath("/home/user/oh-my-openagent.jsonc")).toBe( + "/home/user/oh-my-openagent.jsonc.migrations.json", + ) + }) + }) + + describe("readAppliedMigrations", () => { + test("returns an empty set when no sidecar exists", () => { + const configPath = join(workdir, "oh-my-openagent.json") + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns the applied migrations listed in a well-formed sidecar", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")).toBe(true) + expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6")).toBe(true) + }) + + test("returns an empty set on malformed JSON instead of throwing", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), "{ this is not json") + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns an empty set when the sidecar payload has the wrong shape", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), JSON.stringify({ appliedMigrations: "not-an-array" })) + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("ignores non-string entries inside appliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: ["model-version:a->b", 42, null, "model-version:c->d"], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:a->b")).toBe(true) + expect(applied.has("model-version:c->d")).toBe(true) + }) + }) + + describe("writeAppliedMigrations", () => { + test("creates the sidecar with the given migration keys", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + + const ok = writeAppliedMigrations(configPath, migrations) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual(["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"]) + }) + + test("writes entries in sorted order for stable diffs", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:z->y", + "model-version:a->b", + "model-version:m->n", + ]) + + writeAppliedMigrations(configPath, migrations) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual([ + "model-version:a->b", + "model-version:m->n", + "model-version:z->y", + ]) + }) + + test("creates parent directories if they do not exist yet", () => { + const nested = join(workdir, "nested", "dir", "that", "does", "not", "exist") + const configPath = join(nested, "oh-my-openagent.json") + // Parent chain intentionally not created. + + const ok = writeAppliedMigrations(configPath, new Set(["model-version:a->b"])) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + }) + + test("round-trips via readAppliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.jsonc") + const original = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ]) + + writeAppliedMigrations(configPath, original) + const roundTripped = readAppliedMigrations(configPath) + + expect(roundTripped).toEqual(original) + }) + }) +}) diff --git a/src/shared/migration/migrations-sidecar.ts b/src/shared/migration/migrations-sidecar.ts new file mode 100644 index 000000000..cd0088922 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.ts @@ -0,0 +1,92 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { log } from "../logger" +import { writeFileAtomically } from "../write-file-atomically" + +/** + * Sidecar state file that tracks applied config migrations outside the user's + * config file. + * + * Why this exists (#3263): users who revert an auto-migrated value (e.g. + * `gpt-5.4` → `gpt-5.3-codex`) and then delete the `_migrations` field from + * their config would fall into an infinite migration loop — every startup + * re-applied the migration because there was no memory of the previous + * application. The sidecar remembers applied migrations even when the user + * scrubs the config, and only "resets" when the user explicitly deletes both + * the config and the sidecar. + * + * The sidecar lives next to the config file as + * `.migrations.json`. One sidecar per config file. The file + * format is a flat JSON object: + * + * { + * "appliedMigrations": [ + * "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + * "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6" + * ] + * } + */ + +export interface MigrationsSidecar { + appliedMigrations: string[] +} + +export function getSidecarPath(configPath: string): string { + return `${configPath}.migrations.json` +} + +/** + * Read the set of applied migration keys from the sidecar next to + * `configPath`. Returns an empty set on any read or parse failure so the + * caller can still trust the return value and safely fall back to the + * config's `_migrations` field. + */ +export function readAppliedMigrations(configPath: string): Set { + const sidecarPath = getSidecarPath(configPath) + try { + if (!fs.existsSync(sidecarPath)) { + return new Set() + } + const content = fs.readFileSync(sidecarPath, "utf-8") + const parsed = JSON.parse(content) as unknown + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as MigrationsSidecar).appliedMigrations) + ) { + return new Set((parsed as MigrationsSidecar).appliedMigrations.filter((m): m is string => typeof m === "string")) + } + return new Set() + } catch (err) { + log(`[migration] Failed to read migrations sidecar at ${sidecarPath}`, err) + return new Set() + } +} + +/** + * Persist the given set of applied migration keys to the sidecar next to + * `configPath`. The sidecar is written atomically. Returns true on success, + * false if the write failed (the caller can still proceed — the next + * startup will re-run the migration, which is idempotent by design). + */ +export function writeAppliedMigrations(configPath: string, migrations: Set): boolean { + const sidecarPath = getSidecarPath(configPath) + const body: MigrationsSidecar = { + appliedMigrations: Array.from(migrations).sort(), + } + try { + // Ensure the parent directory exists in case the config file was created + // out-of-band. We intentionally do NOT create the sidecar when the migration + // set is empty — there is nothing to remember. + const parentDir = path.dirname(sidecarPath) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + writeFileAtomically(sidecarPath, JSON.stringify(body, null, 2) + "\n") + return true + } catch (err) { + log(`[migration] Failed to write migrations sidecar at ${sidecarPath}`, err) + return false + } +} diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index 23a02c132..292aac718 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test" + +// Isolate from other tests that mock.module the logger (CI cross-contamination fix) +mock.module("./logger", () => ({ log: (..._args: unknown[]) => {} })) + import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver" import * as logger from "./logger" import * as connectedProvidersCache from "./connected-providers-cache" diff --git a/src/shared/session-prompt-params-helpers.ts b/src/shared/session-prompt-params-helpers.ts index 7ce24c826..f50707956 100644 --- a/src/shared/session-prompt-params-helpers.ts +++ b/src/shared/session-prompt-params-helpers.ts @@ -20,12 +20,12 @@ export function applySessionPromptParams( const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } setSessionPromptParams(sessionID, { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), }) } diff --git a/src/shared/session-prompt-params-state.test.ts b/src/shared/session-prompt-params-state.test.ts index b97a80565..d52670be6 100644 --- a/src/shared/session-prompt-params-state.test.ts +++ b/src/shared/session-prompt-params-state.test.ts @@ -18,9 +18,9 @@ describe("session-prompt-params-state", () => { const params = { temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", - maxTokens: 4096, }, } diff --git a/src/shared/session-prompt-params-state.ts b/src/shared/session-prompt-params-state.ts index 36e956cfc..1df7d526f 100644 --- a/src/shared/session-prompt-params-state.ts +++ b/src/shared/session-prompt-params-state.ts @@ -1,6 +1,7 @@ export type SessionPromptParams = { temperature?: number topP?: number + maxOutputTokens?: number options?: Record } @@ -10,6 +11,7 @@ export function setSessionPromptParams(sessionID: string, params: SessionPromptP sessionPromptParams.set(sessionID, { ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), ...(params.topP !== undefined ? { topP: params.topP } : {}), + ...(params.maxOutputTokens !== undefined ? { maxOutputTokens: params.maxOutputTokens } : {}), ...(params.options !== undefined ? { options: { ...params.options } } : {}), }) } @@ -21,6 +23,7 @@ export function getSessionPromptParams(sessionID: string): SessionPromptParams | return { ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), ...(params.topP !== undefined ? { topP: params.topP } : {}), + ...(params.maxOutputTokens !== undefined ? { maxOutputTokens: params.maxOutputTokens } : {}), ...(params.options !== undefined ? { options: { ...params.options } } : {}), } } diff --git a/src/shared/system-directive.test.ts b/src/shared/system-directive.test.ts index 9da4c9563..2626bb771 100644 --- a/src/shared/system-directive.test.ts +++ b/src/shared/system-directive.test.ts @@ -144,6 +144,50 @@ const x = 1; const directive = ` ${createSystemDirective("TEST")}` expect(isSystemDirective(directive)).toBe(true) }) + + test("#given a ralph-loop ULW continuation prefixed with 'ultrawork ' #when checking system directive #then returns true", () => { + // given + const directive = `ultrawork ${createSystemDirective("RALPH LOOP 2/500")}\n\nYour previous attempt did not output the completion promise.` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given a continuation prefixed with 'ulw ' shorthand #when checking system directive #then returns true", () => { + // given + const directive = `ulw ${createSystemDirective("ULTRAWORK LOOP VERIFICATION 1/500")}\n\nYou already emitted DONE.` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given a continuation prefixed with uppercase 'ULTRAWORK ' #when checking system directive #then returns true", () => { + // given + const directive = `ULTRAWORK ${createSystemDirective("RALPH LOOP 5/500")}` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given user text that legitimately starts with 'ultrawork' word #when no directive follows #then returns false", () => { + // given + const text = "ultrawork is a great mode but I have a question about it" + + // when + const result = isSystemDirective(text) + + // then + expect(result).toBe(false) + }) }) describe("integration with keyword detection", () => { diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index f2ae8c602..001017aa5 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -7,6 +7,8 @@ export const SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE" +const SYSTEM_DIRECTIVE_LEADING_KEYWORD_PATTERN = /^\s*(?:ultrawork|ulw)\s+/i + /** * Creates a system directive header with the given type. * @param type - The directive type (e.g., "TODO CONTINUATION", "RALPH LOOP") @@ -23,7 +25,12 @@ export function createSystemDirective(type: string): string { * @returns true if the message is a system directive */ export function isSystemDirective(text: string): boolean { - return text.trimStart().startsWith(SYSTEM_DIRECTIVE_PREFIX) + const trimmed = text.trimStart() + if (trimmed.startsWith(SYSTEM_DIRECTIVE_PREFIX)) { + return true + } + const withoutLeadingKeyword = trimmed.replace(SYSTEM_DIRECTIVE_LEADING_KEYWORD_PATTERN, "") + return withoutLeadingKeyword.startsWith(SYSTEM_DIRECTIVE_PREFIX) } /** diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index baa59fb78..404e3fee0 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -190,10 +190,10 @@ describe("executeSync", () => { expect(promptInput?.body.temperature).toBe(0.12) expect(promptInput?.body.topP).toBe(0.34) expect(promptInput?.body.options).toEqual({ - maxTokens: 5678, reasoningEffort: "medium", thinking: { type: "disabled" }, }) + expect(promptInput?.body.maxOutputTokens).toBe(5678) }) test("records metadata with description and created session id", async () => { diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index 096a80216..f0f65d7e1 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -43,12 +43,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } return { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), } } diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 4655ec976..84a7bc644 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -204,6 +204,50 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => ]) }) + testFn("strips leading zwsp from agent name before launching background task", async () => { + //#given - display-sorted agent names should be normalized before manager launch + const launchCalls: unknown[] = [] + const manager = { + launch: async (input: unknown) => { + launchCalls.push(input) + return { + id: "bg_clean_agent", + sessionID: "ses_clean_agent", + description: "Clean agent", + agent: "sisyphus-junior", + status: "running", + } + }, + getTask: () => ({ sessionID: "ses_clean_agent" }), + } + + //#when + await executeBackgroundTask( + { + description: "Clean agent", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_clean_agent", + metadata: async () => {}, + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_clean_agent" }, + "\u200Bsisyphus-junior", + undefined, + undefined, + undefined, + ) + + //#then + expectFn(launchCalls).toHaveLength(1) + expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior") + }) + testFn("keeps launched background task alive when parent aborts before session id resolves", async () => { //#given - parallel tool execution can abort the parent call after launch succeeds const metadataCalls: any[] = [] @@ -345,6 +389,54 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => expectFn(result).toContain("interrupt") }) + testFn("reports failure when manager marks task as error during session startup", async () => { + //#given - session created but startTask throws before prompt is sent + const metadataCalls: any[] = [] + let reads = 0 + const manager = { + launch: async () => ({ + id: "bg_crash_before_prompt", + sessionID: undefined, + description: "Crash before prompt", + agent: "explore", + status: "pending", + }), + getTask: () => { + reads += 1 + if (reads >= 2) { + return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" } + } + return { sessionID: undefined, status: "pending" } + }, + } + + //#when + const result = await executeBackgroundTask( + { + description: "Crash before prompt", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_crash", + metadata: async (value: any) => metadataCalls.push(value), + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_crash" }, + "explore", + undefined, + undefined, + undefined, + ) + + //#then - polling loop should detect terminal status and report failure + expectFn(result).toContain("Task failed to start") + expectFn(result).toContain("error") + }) + testFn("keeps sibling background launch alive when two tasks start concurrently", async () => { //#given - one aborted parent call should not interrupt a sibling launch from the same parent session const firstAbortController = new AbortController() diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index 0dbb042ab..184325ec9 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -10,6 +10,7 @@ import { getSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" import { setSessionFallbackChain } from "../../hooks/model-fallback/hook" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" function continueSessionSetup(args: { taskID: string @@ -62,11 +63,12 @@ export async function executeBackgroundTask( try { const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd - const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled) + const normalizedAgent = stripAgentListSortPrefix(agentToUse) + const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled) const task = await manager.launch({ description: args.description, prompt: effectivePrompt, - agent: agentToUse, + agent: normalizedAgent, parentSessionID: parentContext.sessionID, parentMessageID: parentContext.messageID, parentModel: parentContext.model, @@ -156,7 +158,7 @@ Do NOT call background_output now. Wait for notification first return formatDetailedError(error, { operation: "Launch background task", args, - agent: agentToUse, + agent: stripAgentListSortPrefix(agentToUse), category: args.category, }) } diff --git a/src/tools/delegate-task/category-resolver.test.ts b/src/tools/delegate-task/category-resolver.test.ts index 1b2ca185f..4a52f4158 100644 --- a/src/tools/delegate-task/category-resolver.test.ts +++ b/src/tools/delegate-task/category-resolver.test.ts @@ -197,7 +197,7 @@ describe("resolveCategoryExecution", () => { if (!result.actualModel || !result.categoryModel) { throw new Error("Expected resolved model and category model") } - expect(result.actualModel).toBe("openai/gpt-5.4 high") + expect(result.actualModel).toBe("openai/gpt-5.4") expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.4", @@ -484,4 +484,32 @@ describe("resolveCategoryExecution", () => { }) expect(result.fallbackChain).toBeUndefined() }) + + test("does not inherit hardcoded fallbackChain when sisyphus-junior model override is set [regression #2941]", async () => { + //#given + const args = { + category: "quick", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + executorCtx.sisyphusJuniorModel = "anthropic/claude-sonnet-4-6" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6") + + //#then + expect(result.error).toBeUndefined() + expect(result.actualModel).toBe("anthropic/claude-sonnet-4-6") + expect(result.categoryModel).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + variant: undefined, + }) + expect(result.fallbackChain).toBeUndefined() + }) }) diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index 366c77568..fa0f1475e 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -275,6 +275,6 @@ Available categories: ${categoryNames.join(", ")}`, actualModel, isUnstableAgent, // Don't use hardcoded fallback chain when resolution was skipped (cold cache) - fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel) ? undefined : requirement?.fallbackChain), + fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel || overrideModel) ? undefined : requirement?.fallbackChain), } } diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 510bcf80d..bff305b13 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -325,7 +325,7 @@ export const PLAN_AGENT_NAMES = ["plan"] export function isPlanAgent(agentName: string | undefined): boolean { if (!agentName) return false const lowerName = agentName.toLowerCase().trim() - return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name)) + return PLAN_AGENT_NAMES.some(name => lowerName === name) } /** @@ -342,7 +342,5 @@ export function isPlanFamily(category: string | undefined): boolean export function isPlanFamily(category: string | undefined): boolean { if (!category) return false const lowerCategory = category.toLowerCase().trim() - return PLAN_FAMILY_NAMES.some( - (name) => lowerCategory === name || lowerCategory.includes(name) - ) + return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name) } diff --git a/src/tools/delegate-task/model-selection.test.ts b/src/tools/delegate-task/model-selection.test.ts index 3bc7c2c88..6350646dc 100644 --- a/src/tools/delegate-task/model-selection.test.ts +++ b/src/tools/delegate-task/model-selection.test.ts @@ -254,6 +254,100 @@ describe("resolveModelForDelegateTask", () => { }) }) + describe("#given user model override includes variant syntax", () => { + describe("#when userModel contains space-separated variant", () => { + test("#then extracts the variant and returns the base model separately", () => { + const result = resolveModelForDelegateTask({ + userModel: "openai/gpt-5.4 high", + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(["openai/gpt-5.4"]), + }) + + expect(result).toEqual({ model: "openai/gpt-5.4", variant: "high" }) + }) + }) + + describe("#when userModel contains parenthesized variant", () => { + test("#then extracts the variant and returns the base model separately", () => { + const result = resolveModelForDelegateTask({ + userModel: "openai/gpt-5.4(max)", + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + availableModels: new Set(), + }) + + expect(result).toEqual({ model: "openai/gpt-5.4", variant: "max" }) + }) + }) + + describe("#when userModel has no variant syntax", () => { + test("#then returns the model without a variant (backward compat)", () => { + const result = resolveModelForDelegateTask({ + userModel: "openai/gpt-5.4", + availableModels: new Set(), + }) + + expect(result).toEqual({ model: "openai/gpt-5.4" }) + }) + }) + + describe("#when userModel has a non-variant suffix (e.g. -high in model name)", () => { + test("#then preserves the full model name without extracting a variant", () => { + const result = resolveModelForDelegateTask({ + userModel: "new-api-openai/gpt-5.4-high", + availableModels: new Set(), + }) + + expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" }) + }) + }) + }) + + describe("#given user-configured category model includes variant syntax", () => { + beforeEach(() => { + hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true) + hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true) + }) + + describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a space-separated variant", () => { + test("#then extracts the variant and returns the base model separately", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "openai/gpt-5.4 medium", + isUserConfiguredCategoryModel: true, + availableModels: new Set(["openai/gpt-5.4"]), + }) + + expect(result).toEqual({ model: "openai/gpt-5.4", variant: "medium" }) + }) + }) + + describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a parenthesized variant", () => { + test("#then extracts the variant and returns the base model separately", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "openai/gpt-5.4(xhigh)", + isUserConfiguredCategoryModel: true, + availableModels: new Set(), + }) + + expect(result).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" }) + }) + }) + + describe("#when categoryDefaultModel with isUserConfiguredCategoryModel has no variant", () => { + test("#then returns the model without a variant (backward compat)", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "new-api-openai/gpt-5.4-high", + isUserConfiguredCategoryModel: true, + availableModels: new Set(["openai/gpt-5.4"]), + }) + + expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" }) + }) + }) + }) + describe("#given only connected providers cache exists (no provider-models cache)", () => { beforeEach(() => { hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true) diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 1e7ce2c4e..cef7df752 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -56,6 +56,10 @@ export function resolveModelForDelegateTask(input: { }): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined { const userModel = normalizeModel(input.userModel) if (userModel) { + const parsed = parseUserFallbackModel(userModel) + if (parsed?.variant) { + return { model: parsed.baseModel, variant: parsed.variant } + } return { model: userModel } } @@ -75,6 +79,10 @@ export function resolveModelForDelegateTask(input: { log("[resolveModelForDelegateTask] using user-configured category model (bypass validation)", { categoryDefaultModel: categoryDefault, }) + const parsed = parseUserFallbackModel(categoryDefault) + if (parsed?.variant) { + return { model: parsed.baseModel, variant: parsed.variant } + } return { model: categoryDefault } } diff --git a/src/tools/delegate-task/prompt-builder.test.ts b/src/tools/delegate-task/prompt-builder.test.ts new file mode 100644 index 000000000..9c31fdefc --- /dev/null +++ b/src/tools/delegate-task/prompt-builder.test.ts @@ -0,0 +1,125 @@ +declare const require: (name: string) => unknown +const { describe, test, expect } = require("bun:test") as { + describe: (name: string, fn: () => void) => void + test: (name: string, fn: () => void) => void + expect: (value: unknown) => { + toBe: (expected: unknown) => void + toContain: (expected: string) => void + toBeUndefined: () => void + toBeDefined: () => void + not: { + toContain: (expected: string) => void + toBeUndefined: () => void + } + } +} + +import { buildSystemContent } from "./prompt-builder" +import type { AvailableSkill, AvailableCategory } from "../../agents/dynamic-agent-prompt-builder" + +describe("prompt-builder", () => { + describe("buildSystemContent", () => { + describe("#given non-plan agent with availableSkills", () => { + test("#when availableSkills contains project-level skills #then system content includes available_skills section", () => { + // given + const availableSkills: AvailableSkill[] = [ + { name: "git-master", description: "Git workflow automation", location: "plugin" }, + { name: "my-project-skill", description: "Project-specific deployment", location: "project" }, + ] + const availableCategories: AvailableCategory[] = [ + { name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" }, + ] + + // when + const result = buildSystemContent({ + agentName: "sisyphus-junior", + availableSkills, + availableCategories, + }) + + // then + expect(result).toBeDefined() + expect(result).toContain("my-project-skill") + expect(result).toContain("git-master") + }) + + test("#when agent is explore #then system content includes available_skills section", () => { + // given + const availableSkills: AvailableSkill[] = [ + { name: "code-review", description: "Review code quality", location: "project" }, + ] + + // when + const result = buildSystemContent({ + agentName: "explore", + availableSkills, + }) + + // then + expect(result).toBeDefined() + expect(result).toContain("code-review") + }) + + test("#when availableSkills is empty #then system content does not include available_skills section", () => { + // given + const availableSkills: AvailableSkill[] = [] + + // when + const result = buildSystemContent({ + agentName: "sisyphus-junior", + availableSkills, + categoryPromptAppend: "some category context", + }) + + // then + expect(result).toBeDefined() + expect(result).not.toContain("available_skills") + }) + }) + + describe("#given plan agent with availableSkills", () => { + test("#when availableSkills provided #then system content includes plan agent prepend with skills", () => { + // given + const availableSkills: AvailableSkill[] = [ + { name: "git-master", description: "Git workflow automation", location: "plugin" }, + ] + const availableCategories: AvailableCategory[] = [ + { name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" }, + ] + + // when + const result = buildSystemContent({ + agentName: "plan", + availableSkills, + availableCategories, + }) + + // then + expect(result).toBeDefined() + expect(result).toContain("git-master") + expect(result).toContain("AVAILABLE SKILLS") + }) + }) + + describe("#given non-plan agent with agentsContext override", () => { + test("#when agentsContext is provided #then it takes precedence and skills section is appended", () => { + // given + const availableSkills: AvailableSkill[] = [ + { name: "deploy-skill", description: "Deployment automation", location: "project" }, + ] + + // when + const result = buildSystemContent({ + agentName: "sisyphus-junior", + agentsContext: "Custom agent context here", + availableSkills, + }) + + // then + expect(result).toBeDefined() + expect(result).toContain("Custom agent context here") + expect(result).toContain("deploy-skill") + }) + }) + }) +}) diff --git a/src/tools/delegate-task/prompt-builder.ts b/src/tools/delegate-task/prompt-builder.ts index 1672eea74..838fac93f 100644 --- a/src/tools/delegate-task/prompt-builder.ts +++ b/src/tools/delegate-task/prompt-builder.ts @@ -1,4 +1,5 @@ import type { BuildSystemContentInput } from "./types" +import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants" import { buildSystemContentWithTokenLimit } from "./token-limiter" @@ -21,6 +22,22 @@ ${TDD_LINE}` return PLAN_AGENT_PROMPT_BASE } +function buildAvailableSkillsSection(skills: AvailableSkill[]): string { + if (skills.length === 0) { + return "" + } + + const rows = skills + .map((s) => `- \`${s.name}\`: ${s.description || s.name}`) + .join("\n") + + return ` +Skills provide specialized instructions. Load via load_skills parameter when delegating tasks. + +${rows} +` +} + function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean { if (!model) { return false @@ -51,10 +68,20 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und availableSkills, } = input - const planAgentPrepend = isPlanAgent(agentName) + const isPlan = isPlanAgent(agentName) + const planAgentPrepend = isPlan ? buildPlanAgentSystemPrepend(availableCategories, availableSkills) : "" + const skillsSection = !isPlan + ? buildAvailableSkillsSection(availableSkills ?? []) + : "" + + const baseAgentsContext = agentsContext ?? planAgentPrepend + const effectiveAgentsContext = !isPlan && skillsSection + ? [baseAgentsContext, skillsSection].filter(Boolean).join("\n\n") + : baseAgentsContext + const effectiveMaxPromptTokens = maxPromptTokens ?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined) @@ -63,7 +90,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und skillContent, skillContents, categoryPromptAppend, - agentsContext: agentsContext ?? planAgentPrepend, + agentsContext: effectiveAgentsContext, planAgentPrepend, }, effectiveMaxPromptTokens diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index f5a255c70..8ba7cdd8e 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -89,9 +89,10 @@ Create the work plan directly - that's your job as the planning agent.`, const callableAgents = agents.filter((agent) => isTaskCallableAgentMode(agent.mode)) - const resolvedDisplayName = getAgentDisplayName(agentToUse) + const resolvedDisplayName = getAgentDisplayName(agentToUse).replace(/^\u200B+/, "") + const normalizedAgentToUse = agentToUse.replace(/^\u200B+/, "") const matchedAgent = callableAgents.find( - (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + (agent) => agent.name.toLowerCase() === normalizedAgentToUse.toLowerCase() || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase() ) if (!matchedAgent) { diff --git a/src/tools/delegate-task/sync-prompt-sender.test.ts b/src/tools/delegate-task/sync-prompt-sender.test.ts index 32970e72a..f86e87997 100644 --- a/src/tools/delegate-task/sync-prompt-sender.test.ts +++ b/src/tools/delegate-task/sync-prompt-sender.test.ts @@ -277,15 +277,15 @@ bunDescribe("sendSyncPrompt", () => { bunExpect(promptArgs.body.options).toEqual({ reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }) + bunExpect(promptArgs.body.maxOutputTokens).toBe(4096) bunExpect(getSessionPromptParams("test-session")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index 489804253..bd38830e5 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -30,12 +30,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } return { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), } } @@ -80,7 +80,7 @@ export async function sendSyncPrompt( const promptArgs = { path: { id: input.sessionID }, body: { - agent: input.agentToUse, + agent: input.agentToUse.replace(/^\u200B+/, ""), system: input.systemContent, tools, parts: [createInternalAgentTextPart(effectivePrompt)], diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index c605fc043..7c09f16ab 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -180,8 +180,8 @@ describe("sisyphus-task", () => { //#given / #when const result = isPlanAgent("planner") - //#then - "planner" contains "plan" so it matches via includes - expect(result).toBe(true) + //#then - "planner" is NOT an exact match for "plan" (T37 exact match fix) + expect(result).toBe(false) }) test("returns true for case-insensitive match 'PLAN'", () => { @@ -2963,6 +2963,7 @@ describe("sisyphus-task", () => { // then - sisyphus-junior override model should be used, not category default expect(launchInput.model.providerID).toBe("anthropic") expect(launchInput.model.modelID).toBe("claude-sonnet-4-6") + expect(launchInput.fallbackChain).toBeUndefined() }) test("sisyphus-junior model override works with user-defined category (#1295)", async () => { diff --git a/src/tools/look-at/tools.test.ts b/src/tools/look-at/tools.test.ts index 63713041b..9067032de 100644 --- a/src/tools/look-at/tools.test.ts +++ b/src/tools/look-at/tools.test.ts @@ -659,4 +659,112 @@ describe("look-at tool", () => { expect(filePart.url).toContain("base64") }) }) + + describe("createLookAt prompt conditional on Read availability", () => { + const captureLastPromptBody = () => { + const captured: { body: any } = { body: undefined } + const mockClient = { + app: { + agents: async () => ({ data: [] }), + }, + session: { + get: async () => ({ data: { directory: "/project" } }), + create: async () => ({ data: { id: "ses_prompt_conditional" } }), + prompt: async (input: any) => { + captured.body = input.body + return { data: {} } + }, + messages: async () => ({ + data: [ + { info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "ok" }] }, + ], + }), + }, + } + return { mockClient, captured } + } + + const buildToolContext = (): ToolContext => ({ + sessionID: "parent-session", + messageID: "parent-message", + agent: "sisyphus", + directory: "/project", + worktree: "/project", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }) + + // given file_path mode where Read tool is disabled in invocation + // when LookAt tool sends prompt to multimodal-looker + // then prompt instructs agent to analyze the attached file directly without using Read + test("instructs agent to analyze attached file when Read is disabled (file_path mode)", async () => { + const { mockClient, captured } = captureLastPromptBody() + + const tool = createLookAt({ + client: mockClient, + directory: "/project", + } as any) + + await tool.execute( + { file_path: "/test/file.png", goal: "describe contents" }, + buildToolContext(), + ) + + expect(captured.body.tools.read).toBe(false) + const promptPart = captured.body.parts.find((p: any) => p.type === "text") + expect(promptPart).toBeDefined() + const promptText: string = promptPart.text + expect(promptText).toContain("attached") + expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i) + expect(promptText).not.toMatch(/\buse\s+Read\b/i) + }) + + // given image_data mode where no file path exists and Read is disabled + // when LookAt tool sends prompt to multimodal-looker + // then prompt instructs agent to analyze the attached image directly without referencing Read or file path + test("instructs agent to analyze attached image when image_data is provided", async () => { + const { mockClient, captured } = captureLastPromptBody() + + const tool = createLookAt({ + client: mockClient, + directory: "/project", + } as any) + + await tool.execute( + { image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "describe image" }, + buildToolContext(), + ) + + expect(captured.body.tools.read).toBe(false) + const promptPart = captured.body.parts.find((p: any) => p.type === "text") + expect(promptPart).toBeDefined() + const promptText: string = promptPart.text + expect(promptText).toContain("attached") + expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i) + expect(promptText).not.toMatch(/\buse\s+Read\b/i) + }) + + // given prompt is generated for any invocation where Read is denied + // when LookAt tool sends prompt to multimodal-looker + // then prompt explicitly tells the agent NOT to attempt Read tool + test("explicitly warns the agent not to attempt Read when Read is disabled", async () => { + const { mockClient, captured } = captureLastPromptBody() + + const tool = createLookAt({ + client: mockClient, + directory: "/project", + } as any) + + await tool.execute( + { file_path: "/test/file.pdf", goal: "extract text" }, + buildToolContext(), + ) + + const promptPart = captured.body.parts.find((p: any) => p.type === "text") + const promptText: string = promptPart.text + // The prompt must mention the agent cannot use Read so the agent does not hallucinate + expect(promptText.toLowerCase()).toContain("read tool") + }) + }) }) diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 773d334d0..1296afd29 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -129,7 +129,15 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { return "Error: Must provide either 'file_path' or 'image_data'." } - const prompt = `Analyze this ${isBase64Input ? "image" : "file"} and extract the requested information. + const readEnabled = false + const subjectNoun = isBase64Input ? "image" : "file" + const sourceClause = readEnabled + ? `Use the Read tool on the provided file path to load its contents, then analyze it.` + : `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.` + + const prompt = `Analyze the attached ${subjectNoun} and extract the requested information. + +${sourceClause} Goal: ${args.goal} @@ -182,7 +190,7 @@ Original error: ${createResult.error}` task: false, call_omo_agent: false, look_at: false, - read: false, + read: readEnabled, }, parts: [ { type: "text", text: prompt }, diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts index 197ee62dc..2e1876575 100644 --- a/src/tools/skill-mcp/tools.ts +++ b/src/tools/skill-mcp/tools.ts @@ -166,6 +166,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition serverName: args.mcp_name, skillName: found.skill.name, sessionID, + scope: found.skill.scope, } const context: SkillMcpServerContext = { diff --git a/src/tools/skill/mcp-capability-formatter.ts b/src/tools/skill/mcp-capability-formatter.ts index a7371480f..6e731bf0d 100644 --- a/src/tools/skill/mcp-capability-formatter.ts +++ b/src/tools/skill/mcp-capability-formatter.ts @@ -23,6 +23,7 @@ export async function formatMcpCapabilities( serverName, skillName: skill.name, sessionID, + scope: skill.scope, } const context: SkillMcpServerContext = { config, diff --git a/src/tools/task/task-list.ts b/src/tools/task/task-list.ts index 3bdce05dd..480015b59 100644 --- a/src/tools/task/task-list.ts +++ b/src/tools/task/task-list.ts @@ -37,8 +37,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript return JSON.stringify({ tasks: [] }) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allTasks: any[] = [] + const allTasks: TaskObject[] = [] for (const fileId of files) { const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) if (task) { @@ -56,7 +55,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript // Build summary with filtered blockedBy const summaries: TaskSummary[] = activeTasks.map((task) => { // Filter blockedBy to only include unresolved (non-completed) blockers - const unresolvedBlockers = (task.blockedBy ?? []).filter((blockerId: string) => { + const unresolvedBlockers = task.blockedBy.filter((blockerId: string) => { const blockerTask = taskMap.get(blockerId) // Include if blocker doesn't exist (missing) or if it's not completed return !blockerTask || blockerTask.status !== "completed" diff --git a/src/tools/task/task-update.ts b/src/tools/task/task-update.ts index 7b3191b5f..b56bd9add 100644 --- a/src/tools/task/task-update.ts +++ b/src/tools/task/task-update.ts @@ -114,12 +114,12 @@ async function handleUpdate( const addBlocks = args.addBlocks as string[] | undefined; if (addBlocks) { - task.blocks = [...new Set([...(task.blocks ?? []), ...addBlocks])]; + task.blocks = [...new Set([...task.blocks, ...addBlocks])]; } const addBlockedBy = args.addBlockedBy as string[] | undefined; if (addBlockedBy) { - task.blockedBy = [...new Set([...(task.blockedBy ?? []), ...addBlockedBy])]; + task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])]; } if (validatedArgs.metadata !== undefined) {