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 1730f2b26..2a84ef907 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,4 +1,4 @@ -import * as z from "zod" +import { z } from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" export function createOhMyOpenCodeJsonSchema(): Record { @@ -8,10 +8,10 @@ export function createOhMyOpenCodeJsonSchema(): Record { }) 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/signatures/cla.json b/signatures/cla.json index a0b764eb3..704b21266 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2607,6 +2607,54 @@ "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 + }, + { + "name": "gwegwe1234", + "id": 43298107, + "comment_id": 4211103484, + "created_at": "2026-04-09T02:46:26Z", + "repoId": 1108837393, + "pullRequestNo": 3264 } ] } \ No newline at end of file diff --git a/src/agents/delegation-trust-prompt.test.ts b/src/agents/delegation-trust-prompt.test.ts index 03c4ea49a..2de84132a 100644 --- a/src/agents/delegation-trust-prompt.test.ts +++ b/src/agents/delegation-trust-prompt.test.ts @@ -114,6 +114,8 @@ describe("delegation trust prompt rules", () => { expect(prompt).toContain("do only non-overlapping work simultaneously") expect(prompt).toContain("Continue only with non-overlapping work") expect(prompt).toContain("DO NOT perform the same search yourself") + expect(prompt).toContain("Do not use `apply_patch`") + expect(prompt).toContain("`edit` and `write`") }) test("Sisyphus-Junior GPT-5.4 prompt forbids duplicate delegated exploration", () => { diff --git a/src/agents/hephaestus/agent.test.ts b/src/agents/hephaestus/agent.test.ts index 82accf8ee..0885e7839 100644 --- a/src/agents/hephaestus/agent.test.ts +++ b/src/agents/hephaestus/agent.test.ts @@ -192,6 +192,8 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("You build context by examining"); expect(config.prompt).toContain("Never chain together bash commands"); expect(config.prompt).toContain(""); + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); }); test("GPT 5.3-codex model includes GPT-5.3 specific prompt content", () => { @@ -205,6 +207,8 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("Senior Staff Engineer"); expect(config.prompt).toContain("Hard Constraints"); expect(config.prompt).toContain(""); + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); }); test("includes Hephaestus identity in prompt", () => { @@ -219,6 +223,35 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("autonomous deep worker"); }); + test("generic GPT model includes apply_patch workaround guidance", () => { + // given + const model = "openai/gpt-4o"; + + // when + const config = createHephaestusAgent(model); + + // then + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); + }); + + test("GPT models deny apply_patch while non-GPT models do not", () => { + // given + const gpt54Model = "openai/gpt-5.4"; + const gptGenericModel = "openai/gpt-4o"; + const claudeModel = "anthropic/claude-opus-4-6"; + + // when + const gpt54Config = createHephaestusAgent(gpt54Model); + const gptGenericConfig = createHephaestusAgent(gptGenericModel); + const claudeConfig = createHephaestusAgent(claudeModel); + + // then + expect(gpt54Config.permission ?? {}).toHaveProperty("apply_patch", "deny"); + expect(gptGenericConfig.permission ?? {}).toHaveProperty("apply_patch", "deny"); + expect(claudeConfig.permission ?? {}).not.toHaveProperty("apply_patch"); + }); + test("useTaskSystem=true produces Task Discipline prompt", () => { // given const model = "openai/gpt-5.4"; diff --git a/src/agents/hephaestus/agent.ts b/src/agents/hephaestus/agent.ts index cb2005a47..c6ce3bc1b 100644 --- a/src/agents/hephaestus/agent.ts +++ b/src/agents/hephaestus/agent.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; import type { AgentMode, AgentPromptMetadata } from "../types"; -import { isGpt5_4Model, isGpt5_3CodexModel } from "../types"; +import { isGptModel, isGpt5_4Model, isGpt5_3CodexModel } from "../types"; import type { AvailableAgent, AvailableTool, @@ -125,6 +125,7 @@ export function createHephaestusAgent( permission: { question: "allow", call_omo_agent: "deny", + ...(isGptModel(model) ? { apply_patch: "deny" as const } : {}), } as AgentConfig["permission"], reasoningEffort: "medium", }; diff --git a/src/agents/hephaestus/gpt-5-3-codex.ts b/src/agents/hephaestus/gpt-5-3-codex.ts index 732a83afe..93a7ef32a 100644 --- a/src/agents/hephaestus/gpt-5-3-codex.ts +++ b/src/agents/hephaestus/gpt-5-3-codex.ts @@ -448,6 +448,7 @@ ${oracleSection} 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/hephaestus/gpt-5-4.ts b/src/agents/hephaestus/gpt-5-4.ts index 9447d183f..2c0f8410b 100644 --- a/src/agents/hephaestus/gpt-5-4.ts +++ b/src/agents/hephaestus/gpt-5-4.ts @@ -252,7 +252,7 @@ ${antiPatterns} 1. **Explore**: Fire 2-5 explore/librarian agents in parallel + direct tool reads. Goal: complete understanding, not just enough context. 2. **Plan**: List files to modify, specific changes, dependencies, complexity estimate. 3. **Decide**: Trivial (<10 lines, single file) -> self. Complex (multi-file, >100 lines) -> delegate. -4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. +4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. 5. **Verify**: \`lsp_diagnostics\` on all modified files (zero errors) -> run related tests (\`foo.ts\` -> \`foo.test.ts\`) -> typecheck -> build if applicable (exit 0). Fix only issues your changes caused. If verification fails, return to step 1 with a materially different approach. After three attempts: stop, revert to last working state, document what you tried, consult Oracle. If Oracle cannot resolve, ask the user. diff --git a/src/agents/hephaestus/gpt.ts b/src/agents/hephaestus/gpt.ts index bfa7ae4b4..b305d1128 100644 --- a/src/agents/hephaestus/gpt.ts +++ b/src/agents/hephaestus/gpt.ts @@ -311,6 +311,7 @@ ${oracleSection} 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/agent.ts b/src/agents/sisyphus-junior/agent.ts index c8178f7fb..febb2512b 100644 --- a/src/agents/sisyphus-junior/agent.ts +++ b/src/agents/sisyphus-junior/agent.ts @@ -30,6 +30,7 @@ const MODE: AgentMode = "subagent" // Core tools that Sisyphus-Junior must NEVER have access to // Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian const BLOCKED_TOOLS = ["task"] +const GPT_BLOCKED_TOOLS = ["task", "apply_patch"] export const SISYPHUS_JUNIOR_DEFAULTS = { model: "anthropic/claude-sonnet-4-6", @@ -91,13 +92,14 @@ export function createSisyphusJuniorAgentWithOverrides( const promptAppend = override?.prompt_append const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend) + const blockedTools = isGptModel(model) ? GPT_BLOCKED_TOOLS : BLOCKED_TOOLS - const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) + const baseRestrictions = createAgentToolRestrictions(blockedTools) const userPermission = (override?.permission ?? {}) as Record const basePermission = baseRestrictions.permission const merged: Record = { ...userPermission } - for (const tool of BLOCKED_TOOLS) { + for (const tool of blockedTools) { merged[tool] = "deny" } merged.call_omo_agent = "allow" diff --git a/src/agents/sisyphus-junior/gpt-5-3-codex.ts b/src/agents/sisyphus-junior/gpt-5-3-codex.ts index 8394afc7c..02e8d07fa 100644 --- a/src/agents/sisyphus-junior/gpt-5-3-codex.ts +++ b/src/agents/sisyphus-junior/gpt-5-3-codex.ts @@ -92,6 +92,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/gpt-5-4.ts b/src/agents/sisyphus-junior/gpt-5-4.ts index fabd679e8..81e706530 100644 --- a/src/agents/sisyphus-junior/gpt-5-4.ts +++ b/src/agents/sisyphus-junior/gpt-5-4.ts @@ -96,7 +96,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks -4. Always use apply_patch for manual code edits. Do not use cat or echo for file creation/editing. Formatting commands or bulk edits don't need apply_patch +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. 5. Do not chain bash commands with separators - each command should be a separate tool call ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/gpt.ts b/src/agents/sisyphus-junior/gpt.ts index 83339fc11..c69ab7a2a 100644 --- a/src/agents/sisyphus-junior/gpt.ts +++ b/src/agents/sisyphus-junior/gpt.ts @@ -93,6 +93,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/index.test.ts b/src/agents/sisyphus-junior/index.test.ts index dace8bf38..00a4c0377 100644 --- a/src/agents/sisyphus-junior/index.test.ts +++ b/src/agents/sisyphus-junior/index.test.ts @@ -350,6 +350,8 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { expect(result.prompt).toContain("Scope Discipline") expect(result.prompt).toContain("") expect(result.prompt).toContain("Progress Updates") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") }) test("GPT 5.4 model uses GPT-5.4 specific prompt", () => { @@ -362,6 +364,9 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { // then expect(result.prompt).toContain("expert coding agent") expect(result.prompt).toContain("") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") + expect(result.prompt).not.toContain("Always use apply_patch") }) test("GPT 5.3 Codex model uses GPT-5.3-codex specific prompt", () => { @@ -374,6 +379,28 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { // then expect(result.prompt).toContain("Senior Engineer") expect(result.prompt).toContain("") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") + }) + + test("GPT variants deny apply_patch while Claude variants do not", () => { + // given + const gpt54Override = { model: "openai/gpt-5.4" } + const gpt53Override = { model: "openai/gpt-5.3-codex" } + const gptGenericOverride = { model: "openai/gpt-4o" } + const claudeOverride = { model: "anthropic/claude-sonnet-4-6" } + + // when + const gpt54Result = createSisyphusJuniorAgentWithOverrides(gpt54Override) + const gpt53Result = createSisyphusJuniorAgentWithOverrides(gpt53Override) + const gptGenericResult = createSisyphusJuniorAgentWithOverrides(gptGenericOverride) + const claudeResult = createSisyphusJuniorAgentWithOverrides(claudeOverride) + + // then + expect(gpt54Result.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(gpt53Result.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(gptGenericResult.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(claudeResult.permission ?? {}).not.toHaveProperty("apply_patch") }) test("prompt_append is added after base prompt", () => { @@ -494,6 +521,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("expert coding agent") expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") + expect(prompt).toContain("Do not use `apply_patch`") }) test("GPT 5.3 Codex model uses GPT-5.3-codex prompt", () => { @@ -507,6 +535,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("Senior Engineer") expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") + expect(prompt).toContain("Do not use `apply_patch`") }) test("generic GPT model uses generic GPT prompt", () => { @@ -521,6 +550,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") expect(prompt).toContain("Progress Updates") + expect(prompt).toContain("Do not use `apply_patch`") }) test("Claude model prompt contains Claude-specific sections", () => { diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 10bc79797..52442c359 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -499,6 +499,7 @@ export function createSisyphusAgent( permission: { question: "allow", call_omo_agent: "deny", + apply_patch: "deny", } as AgentConfig["permission"], reasoningEffort: "medium", }; @@ -538,6 +539,7 @@ export function createSisyphusAgent( const permission = { question: "allow", call_omo_agent: "deny", + ...(isGptModel(model) ? { apply_patch: "deny" as const } : {}), } as AgentConfig["permission"]; const base = { description: diff --git a/src/agents/sisyphus/gpt-5-4.ts b/src/agents/sisyphus/gpt-5-4.ts index e2c611190..72d641b40 100644 --- a/src/agents/sisyphus/gpt-5-4.ts +++ b/src/agents/sisyphus/gpt-5-4.ts @@ -310,7 +310,7 @@ Every implementation task follows this cycle. No exceptions. Skills: if ANY available skill's domain overlaps with the task, load it NOW via \`skill\` tool and include it in \`load_skills\`. When the connection is even remotely plausible, load the skill - the cost of loading an irrelevant skill is near zero, the cost of missing a relevant one is high. 4. EXECUTE_OR_SUPERVISE - - If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. + If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. If delegated: exhaustive 6-section prompt per \`\` protocol. Session continuity for follow-ups. 5. VERIFY - diff --git a/src/agents/tool-restrictions.test.ts b/src/agents/tool-restrictions.test.ts index 85facdc54..3ae7bfcfe 100644 --- a/src/agents/tool-restrictions.test.ts +++ b/src/agents/tool-restrictions.test.ts @@ -5,6 +5,7 @@ import { createExploreAgent } from "./explore" import { createMomusAgent } from "./momus" import { createMetisAgent } from "./metis" import { createAtlasAgent } from "./atlas" +import { createSisyphusAgent } from "./sisyphus" const TEST_MODEL = "anthropic/claude-sonnet-4-5" @@ -111,4 +112,23 @@ describe("read-only agent tool restrictions", () => { expect(permission["call_omo_agent"]).toBeUndefined() }) }) + + describe("Sisyphus GPT variants", () => { + test("deny apply_patch for GPT models but not Claude models", () => { + // given + const gpt54Agent = createSisyphusAgent("openai/gpt-5.4") + const gptGenericAgent = createSisyphusAgent("openai/gpt-5.2") + const claudeAgent = createSisyphusAgent(TEST_MODEL) + + // when + const gpt54Permission = (gpt54Agent.permission ?? {}) as Record + const gptGenericPermission = (gptGenericAgent.permission ?? {}) as Record + const claudePermission = (claudeAgent.permission ?? {}) as Record + + // then + expect(gpt54Permission["apply_patch"]).toBe("deny") + expect(gptGenericPermission["apply_patch"]).toBe("deny") + expect(claudePermission["apply_patch"]).toBeUndefined() + }) + }) }) 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/checks/model-resolution-cache.test.ts b/src/cli/doctor/checks/model-resolution-cache.test.ts new file mode 100644 index 000000000..df6d68f16 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-cache.test.ts @@ -0,0 +1,150 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync } from "node:fs" +import { join } from "node:path" +import { loadAvailableModelsFromCache } from "./model-resolution-cache" + +describe("loadAvailableModelsFromCache", () => { + const originalXDGCache = process.env.XDG_CACHE_HOME + const originalXDGConfig = process.env.XDG_CONFIG_HOME + let tempDir: string + + beforeEach(() => { + tempDir = join("/tmp", `doctor-cache-test-${Date.now()}`) + mkdirSync(join(tempDir, "cache", "opencode"), { recursive: true }) + mkdirSync(join(tempDir, "config", "opencode"), { recursive: true }) + process.env.XDG_CACHE_HOME = join(tempDir, "cache") + process.env.XDG_CONFIG_HOME = join(tempDir, "config") + }) + + afterEach(() => { + process.env.XDG_CACHE_HOME = originalXDGCache + process.env.XDG_CONFIG_HOME = originalXDGConfig + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("returns cacheExists: false when no models.json and no custom providers", () => { + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(false) + expect(result.providers).toEqual([]) + expect(result.modelCount).toBe(0) + }) + + test("reads providers from models.json cache", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + expect(result.providers).toContain("anthropic") + expect(result.modelCount).toBe(3) + }) + + test("includes custom providers from opencode.json even if not in cache", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + "openai-custom": { + npm: "@ai-sdk/openai-compatible", + models: { "gpt-5.4": {} }, + }, + "my-local-llm": { + npm: "@ai-sdk/openai-compatible", + models: { "local-model": {} }, + }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + expect(result.providers).toContain("openai-custom") + expect(result.providers).toContain("my-local-llm") + }) + + test("deduplicates providers that appear in both cache and opencode.json", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + openai: { models: { "custom-model": {} } }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + const openaiCount = result.providers.filter((p) => p === "openai").length + expect(openaiCount).toBe(1) + }) + + test("returns custom providers even without models.json cache", () => { + // No models.json exists + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + "openai-custom": { + npm: "@ai-sdk/openai-compatible", + models: { "gpt-5.4": {} }, + }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) // custom providers make it effectively "exists" + expect(result.providers).toContain("openai-custom") + }) + + test("reads from opencode.jsonc (JSONC variant)", () => { + writeFileSync( + join(tempDir, "config", "opencode", "opencode.jsonc"), + `{ + // This is a comment + "provider": { + "my-provider": { + "models": { "test-model": {} } + } + } + }` + ) + + const result = loadAvailableModelsFromCache() + expect(result.providers).toContain("my-provider") + }) + + test("ignores malformed opencode.json gracefully", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ openai: { models: { "gpt-5.4": {} } } }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + "this is not valid json {{{", + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + // Should not crash, just skip the config + }) +}) diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts index 7c1b75233..d1e82cc3e 100644 --- a/src/cli/doctor/checks/model-resolution-cache.ts +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -10,10 +10,51 @@ function getOpenCodeCacheDir(): string { return join(homedir(), ".cache", "opencode") } +function getOpenCodeConfigDir(): string { + const xdgConfig = process.env.XDG_CONFIG_HOME + if (xdgConfig) return join(xdgConfig, "opencode") + return join(homedir(), ".config", "opencode") +} + +/** + * Read custom provider names from opencode.json configs. + * Custom providers defined in the user's opencode.json (under the "provider" key) + * are valid at runtime but don't appear in the model cache (models.json), which + * only contains built-in providers from models.dev. This causes false-positive + * warnings in doctor. + */ +function loadCustomProviderNames(): string[] { + const configDir = getOpenCodeConfigDir() + const candidatePaths = [ + join(configDir, "opencode.json"), + join(configDir, "opencode.jsonc"), + ] + + for (const configPath of candidatePaths) { + if (!existsSync(configPath)) continue + try { + const content = readFileSync(configPath, "utf-8") + const data = parseJsonc<{ provider?: Record }>(content) + if (data?.provider && typeof data.provider === "object") { + return Object.keys(data.provider) + } + } catch { + // ignore parse errors + } + } + + return [] +} + export function loadAvailableModelsFromCache(): AvailableModelsInfo { const cacheFile = join(getOpenCodeCacheDir(), "models.json") + const customProviders = loadCustomProviderNames() if (!existsSync(cacheFile)) { + // Even without the cache, custom providers are valid + if (customProviders.length > 0) { + return { providers: customProviders, modelCount: 0, cacheExists: true } + } return { providers: [], modelCount: 0, cacheExists: false } } @@ -21,16 +62,19 @@ export function loadAvailableModelsFromCache(): AvailableModelsInfo { const content = readFileSync(cacheFile, "utf-8") const data = parseJsonc }>>(content) - const providers = Object.keys(data) + const cacheProviders = Object.keys(data) let modelCount = 0 - for (const providerId of providers) { + for (const providerId of cacheProviders) { const models = data[providerId]?.models if (models && typeof models === "object") { modelCount += Object.keys(models).length } } - return { providers, modelCount, cacheExists: true } + // Merge cache providers with custom providers from opencode.json + const allProviders = [...new Set([...cacheProviders, ...customProviders])] + + return { providers: allProviders, modelCount, cacheExists: true } } catch { return { providers: [], modelCount: 0, cacheExists: false } } 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/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 b2c7606f7..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 @@ -1437,6 +1547,7 @@ describe("BackgroundManager.tryCompleteTask", () => { const task = createMockTask({ id: "task-zombie-session", + sessionID: "session-zombie-placeholder", parentSessionID: "parent-zombie", status: "pending", agent: "explore", @@ -1779,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, }, }) }) @@ -2379,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 741efc027..bd8ff2477 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -422,10 +422,6 @@ export class BackgroundManager { this.concurrencyManager.release(key) } - if (item.task.rootSessionID) { - this.unregisterRootDescendant(item.task.rootSessionID) - } - removeTaskToastTracking(item.task.id) // Abort the orphaned session if one was created before the error @@ -1783,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 { @@ -1796,7 +1793,7 @@ export class BackgroundManager { tools?: Record } }>) - const promptContext = resolvePromptContextFromSessionMessages( + promptContext = resolvePromptContextFromSessionMessages( messages, task.parentSessionID, ) @@ -1840,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 }, @@ -1847,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/background-agent/subagent-spawn-limits.test.ts b/src/features/background-agent/subagent-spawn-limits.test.ts index 154718dbd..85824d46c 100644 --- a/src/features/background-agent/subagent-spawn-limits.test.ts +++ b/src/features/background-agent/subagent-spawn-limits.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test" import type { OpencodeClient } from "./constants" -import { resolveSubagentSpawnContext } from "./subagent-spawn-limits" +import { + resolveSubagentSpawnContext, + getMaxSubagentDepth, + DEFAULT_MAX_SUBAGENT_DEPTH, + createSubagentDepthLimitError, + createSubagentDescendantLimitError, + getMaxRootSessionSpawnBudget, + DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET, +} from "./subagent-spawn-limits" function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient { return { @@ -41,4 +49,177 @@ describe("resolveSubagentSpawnContext", () => { await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/) }) }) + + describe("depth calculation smoke tests (regression guard)", () => { + test("root session (no parentID) reports depth 0 and childDepth 1", async () => { + // given - a root session with no parent + const client = createMockClient(async (opts) => { + if (opts.path.id === "root-session") { + return { data: { id: "root-session", parentID: undefined } } + } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "root-session") + + // then + expect(result.rootSessionID).toBe("root-session") + expect(result.parentDepth).toBe(0) + expect(result.childDepth).toBe(1) + }) + + test("depth-1 child reports childDepth 2", async () => { + // given - child -> root chain + const client = createMockClient(async (opts) => { + if (opts.path.id === "child-1") { + return { data: { id: "child-1", parentID: "root-session" } } + } + if (opts.path.id === "root-session") { + return { data: { id: "root-session", parentID: undefined } } + } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "child-1") + + // then + expect(result.rootSessionID).toBe("root-session") + expect(result.parentDepth).toBe(1) + expect(result.childDepth).toBe(2) + }) + + test("depth-2 grandchild reports childDepth 3", async () => { + // given - grandchild -> child -> root chain + const client = createMockClient(async (opts) => { + const sessions: Record = { + "grandchild": { id: "grandchild", parentID: "child" }, + "child": { id: "child", parentID: "root" }, + "root": { id: "root", parentID: undefined }, + } + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "grandchild") + + // then + expect(result.rootSessionID).toBe("root") + expect(result.parentDepth).toBe(2) + expect(result.childDepth).toBe(3) + }) + + test("depth at DEFAULT_MAX_SUBAGENT_DEPTH reports exact max childDepth", async () => { + // given - chain of exactly DEFAULT_MAX_SUBAGENT_DEPTH depth + // With default=3: session-3 -> session-2 -> session-1 -> root + const sessions: Record = { + "root": { id: "root" }, + } + for (let i = 1; i <= DEFAULT_MAX_SUBAGENT_DEPTH; i++) { + sessions[`session-${i}`] = { + id: `session-${i}`, + parentID: i === 1 ? "root" : `session-${i - 1}`, + } + } + + const client = createMockClient(async (opts) => { + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when - resolve from the deepest session + const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}` + const result = await resolveSubagentSpawnContext(client, deepest) + + // then - childDepth should be DEFAULT_MAX_SUBAGENT_DEPTH + 1 (exceeds limit) + expect(result.childDepth).toBe(DEFAULT_MAX_SUBAGENT_DEPTH + 1) + expect(result.parentDepth).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + }) + + test("detects parent cycle and throws", async () => { + // given - A -> B -> A (cycle) + const client = createMockClient(async (opts) => { + const sessions: Record = { + "session-a": { id: "session-a", parentID: "session-b" }, + "session-b": { id: "session-b", parentID: "session-a" }, + } + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when + const result = resolveSubagentSpawnContext(client, "session-a") + + // then + await expect(result).rejects.toThrow(/session parent cycle/) + }) + }) +}) + +describe("getMaxSubagentDepth", () => { + test("returns DEFAULT_MAX_SUBAGENT_DEPTH when no config", () => { + expect(getMaxSubagentDepth()).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + expect(getMaxSubagentDepth(undefined)).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + }) + + test("returns config.maxDepth when provided", () => { + expect(getMaxSubagentDepth({ maxDepth: 5 })).toBe(5) + expect(getMaxSubagentDepth({ maxDepth: 1 })).toBe(1) + expect(getMaxSubagentDepth({ maxDepth: 0 })).toBe(0) + }) + + test("default is 3", () => { + expect(DEFAULT_MAX_SUBAGENT_DEPTH).toBe(3) + }) +}) + +describe("getMaxRootSessionSpawnBudget", () => { + test("returns DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET when no config", () => { + expect(getMaxRootSessionSpawnBudget()).toBe(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET) + }) + + test("returns config.maxDescendants when provided", () => { + expect(getMaxRootSessionSpawnBudget({ maxDescendants: 10 })).toBe(10) + }) + + test("default is 50", () => { + expect(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET).toBe(50) + }) +}) + +describe("createSubagentDepthLimitError", () => { + test("includes childDepth, maxDepth, and session IDs in message", () => { + const error = createSubagentDepthLimitError({ + childDepth: 4, + maxDepth: 3, + parentSessionID: "parent-123", + rootSessionID: "root-456", + }) + + expect(error.message).toContain("child depth 4") + expect(error.message).toContain("maxDepth=3") + expect(error.message).toContain("parent-123") + expect(error.message).toContain("root-456") + expect(error.message).toContain("spawn blocked") + }) +}) + +describe("createSubagentDescendantLimitError", () => { + test("includes descendant count, max, and root session ID", () => { + const error = createSubagentDescendantLimitError({ + rootSessionID: "root-789", + descendantCount: 50, + maxDescendants: 50, + }) + + expect(error.message).toContain("root-789") + expect(error.message).toContain("50") + expect(error.message).toContain("maxDescendants=50") + expect(error.message).toContain("spawn blocked") + }) }) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 65f6ad87e..4326b42e0 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -650,6 +650,65 @@ describe("boulder-state", () => { 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 1d5dc2a59..d570ce525 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress { const lines = content.split(/\r?\n/) // Check if the plan has structured sections (## TODOs / ## Final Verification Wave) - const hasStructuredSections = lines.some((line) => TODO_HEADING_PATTERN.test(line)) + const hasStructuredSections = lines.some( + (line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line), + ) if (hasStructuredSections) { // Structured plan: only count top-level checkboxes with numbered labels @@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress { } function getSimplePlanProgress(content: string): PlanProgress { - const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] - const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] + const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] + const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] const total = uncheckedMatches.length + checkedMatches.length const completed = checkedMatches.length 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-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 367ad6d3e..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" @@ -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", () => { 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 index 157904a00..60cf20ce6 100644 --- a/src/features/skill-mcp-manager/connection-env-vars.test.ts +++ b/src/features/skill-mcp-manager/connection-env-vars.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +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" @@ -89,11 +89,15 @@ function createState(): SkillMcpManagerState { return state } -function createClientInfo(serverName: string): SkillMcpClientInfo { +function createClientInfo( + serverName: string, + scope?: SkillMcpClientInfo["scope"], +): SkillMcpClientInfo { return { serverName, skillName: "env-skill", sessionID: "session-env", + ...(scope !== undefined ? { scope } : {}), } } @@ -125,6 +129,68 @@ afterEach(async () => { }) 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 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 79754e712..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, { trusted: true }) + 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 82ebb9f9c..d908e0b9c 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -44,12 +44,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" })), + 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, spawnTmuxWindow: mockSpawnTmuxWindow, spawnTmuxSession: mockSpawnTmuxSession, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index ad3a3cf27..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`, { 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/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 c621099b6..341839af0 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { PACKAGE_NAME } from "../constants" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" type PluginEntryResult = { entry: string @@ -120,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 bc9fcbc26..cc0ea44c8 100644 --- a/src/hooks/auto-update-checker/constants.test.ts +++ b/src/hooks/auto-update-checker/constants.test.ts @@ -26,4 +26,24 @@ describe("auto-update-checker constants", () => { // 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 9a40ecfb4..9de9fb6a0 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -4,6 +4,16 @@ import { getOpenCodeCacheDir } from "../../shared/data-path" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" 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 index 0ba0a6d27..ce0a6f066 100644 --- a/src/hooks/keyword-detector/hook-ralph-loop.test.ts +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => { 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") diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 1d35bbe3f..ea6348419 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop" import { parseRalphLoopArguments } from "../ralph-loop/command-arguments" 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, @@ -76,6 +81,16 @@ export function createKeywordDetectorHook( } } + 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 } 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/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/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/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/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 2fe074429..ecbe1d37a 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -23,7 +23,7 @@ function normalizePlanLookupValue(value: string): string { .replace(/^["'`]+|["'`]+$/g, "") .toLowerCase() .replace(/[\s_]+/g, "-") - .replace(/[^a-z0-9-]+/g, "-") + .replace(/[^\p{L}\p{N}-]+/gu, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") } diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 1c1c20ae6..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, @@ -444,6 +443,122 @@ You are starting a Sisyphus work session. 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", () => { @@ -467,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 = { @@ -481,8 +596,8 @@ You are starting a Sisyphus work session. output ) - // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + // then - config key, not display name (matches no-sisyphus-gpt / boulder-continuation-injector convention) + expect(output.message.agent).toBe("atlas") }) test("should switch to Atlas even when current session is Sisyphus (regression: #3155)", async () => { @@ -502,7 +617,7 @@ You are starting a Sisyphus work session. ) // atlas is registered in beforeEach, so it must be selected - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas") }) @@ -525,7 +640,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus") }) @@ -553,7 +668,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -588,7 +703,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -623,7 +738,7 @@ You are starting a Sisyphus work session. await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) @@ -713,7 +828,7 @@ You are starting a Sisyphus work session. await firePendingTimers() // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index f916c2eae..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 = activeAgent === "atlas" - ? getAgentListDisplayName(activeAgent) - : getAgentDisplayName(activeAgent) updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - output.message["agent"] = activeAgentDisplayName + 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 514b6f15b..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 () => { diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index fdd12efc1..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, @@ -127,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 }) @@ -168,7 +172,7 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, - agent: promptAgent, + agent: launchAgent ?? promptAgent, model, incompleteCount: freshIncompleteCount, }) @@ -183,7 +187,7 @@ ${todoList}` await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: promptAgent, + agent: launchAgent ?? promptAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 87c674105..162b60f6d 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -150,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/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 ecd95c885..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" diff --git a/src/hooks/todo-continuation-enforcer/token-limit-detection.ts b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts index 25a2fad3d..366ac245f 100644 --- a/src/hooks/todo-continuation-enforcer/token-limit-detection.ts +++ b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts @@ -1,8 +1,6 @@ -const TOKEN_LIMIT_ERROR_NAMES = new Set([ - "contextlengtherror", -]) +import { isRetryableModelError } from "../../shared/model-error-classifier" -const TOKEN_LIMIT_KEYWORDS = [ +const TOKEN_LIMIT_FALLBACK_PATTERNS = [ "prompt is too long", "is too long", "context_length_exceeded", @@ -11,16 +9,29 @@ const TOKEN_LIMIT_KEYWORDS = [ "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 - if (error.name && TOKEN_LIMIT_ERROR_NAMES.has(error.name.toLowerCase())) { - return true + 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_KEYWORDS.some((keyword) => lower.includes(keyword)) + 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 d28874ed8..3d0e61770 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -54,6 +54,7 @@ export interface MessageInfo { export interface MessageWithInfo { info?: MessageInfo + parts?: Array<{ type?: string }> } export interface ResolvedMessageInfo { @@ -65,6 +66,7 @@ export interface ResolvedMessageInfo { export interface ResolveLatestMessageInfoResult { resolvedInfo?: ResolvedMessageInfo encounteredCompaction: boolean + latestMessageWasCompaction: boolean } export interface ContinuationProgressOptions { diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts index c29a3245d..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") 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 a3699668c..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, @@ -165,7 +164,7 @@ describe("createPluginInterface - command.execute.before", () => { ) // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(getSessionAgent("ses-command-atlas")).toBe("atlas") expect(readBoulderState(testDir)?.agent).toBe("atlas") }) diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index 4c2757d23..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 } @@ -87,7 +86,7 @@ describe("createChatMessageHandler - /start-work integration", () => { await handler(input, output) // then - expect(output.message["agent"]).toBe("Sisyphus - Ultraworker") + 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("boulder.json has been created") @@ -116,7 +115,7 @@ describe("createChatMessageHandler - /start-work integration", () => { await handler(input, output) // then - expect(output.message["agent"]).toBe("Sisyphus - Ultraworker") + 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") @@ -403,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" }) @@ -416,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/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 353bfb31e..b77a5e1ff 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -183,30 +183,46 @@ 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") }) }) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index d74287c28..2ccb545ad 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -26,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+/, "") } @@ -57,11 +67,21 @@ 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( 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/migration.test.ts b/src/shared/migration.test.ts index d63e9d2f1..980fa50f2 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -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/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 0d95dd1dd..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[] = [] 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/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/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/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 483a826b9..b34a3cc6c 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -282,6 +282,139 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls.length).toBe(1) expect(deleteCalls[0]).toBe("ses_test_12345678") }) + + test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => { + // This is a smoke test guarding against regressions where the depth limit + // would be silently bypassed (e.g. via a fallback path that hardcodes + // childDepth: 1). + + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const reserveSubagentSpawn = mock(async () => { + throw new Error( + "Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another." + ) + }) + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + manager: { reserveSubagentSpawn }, + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when - executeSyncTask is called from a session at max depth + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then - should propagate the depth limit error and NOT create the session + expect(result).toContain("Subagent spawn blocked") + expect(result).toContain("child depth 4") + expect(result).toContain("maxDepth=3") + expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session") + // critical: createSyncSession must NOT have been called -- if it was, + // the depth guard was bypassed. + expect(addCalls.length).toBe(0) + }) + + test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => { + // Guards against the dangerous fallback path in sync-task.ts that + // hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are + // not functions. With a real manager present, the fallback must NOT be + // taken. + + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + let reservedDepth: number | undefined + const commit = mock(() => 1) + const rollback = mock(() => {}) + const reserveSubagentSpawn = mock(async () => { + // Return a depth that proves the real manager was consulted + reservedDepth = 3 + return { + spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 }, + descendantCount: 5, + commit, + rollback, + } + }) + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const metadataCalls: any[] = [] + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: (input: any) => { metadataCalls.push(input) }, + } + + const mockExecutorCtx = { + manager: { reserveSubagentSpawn }, + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned + expect(reservedDepth).toBe(3) + const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined) + expect(taskMeta).toBeDefined() + expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value) + }) }) export {} diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 07372d8a0..d1ee6ff31 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -37,14 +37,29 @@ export async function executeSyncTask( spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID) } - const spawnContext = spawnReservation?.spawnContext - ?? (typeof manager?.assertCanSpawn === "function" - ? await manager.assertCanSpawn(parentContext.sessionID) - : { - rootSessionID: parentContext.sessionID, - parentDepth: 0, - childDepth: 1, - }) + // Depth/descendant guard. We must NOT silently fall back to childDepth: 1 + // when the manager is unavailable or lacks the spawn methods, because that + // would let subagents recurse without bound. The only safe fallback is + // when the manager genuinely cannot enforce limits (legacy SDK), in which + // case we still record childDepth: 1 but log a warning so regressions are + // visible. + let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number } + if (spawnReservation?.spawnContext) { + spawnContext = spawnReservation.spawnContext + } else if (typeof manager?.assertCanSpawn === "function") { + spawnContext = await manager.assertCanSpawn(parentContext.sessionID) + } else { + log( + "[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " + + "Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.", + { parentSessionID: parentContext.sessionID } + ) + spawnContext = { + rootSessionID: parentContext.sessionID, + parentDepth: 0, + childDepth: 1, + } + } const createSessionResult = await deps.createSyncSession(client, { parentSessionID: parentContext.sessionID, diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index df7ed517c..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'", () => { 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) {