diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 361ce4c59..693240673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,13 @@ jobs: bun-version: "1.3.12" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" + - name: Build plugin + run: bun run build + - name: Run tests run: bun run script/run-ci-tests.ts @@ -59,7 +62,7 @@ jobs: bun-version: "1.3.12" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" @@ -84,7 +87,7 @@ jobs: bun-version: "1.3.12" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" diff --git a/.github/workflows/publish-platform.yml b/.github/workflows/publish-platform.yml index 7d4b5a8e7..cd2b16b6f 100644 --- a/.github/workflows/publish-platform.yml +++ b/.github/workflows/publish-platform.yml @@ -52,7 +52,7 @@ jobs: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fc50d007f..18f18f696 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,7 +41,7 @@ jobs: bun-version: "1.3.11" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" @@ -58,7 +58,7 @@ jobs: bun-version: "1.3.11" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" @@ -159,7 +159,7 @@ jobs: run: npm install -g npm@latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" @@ -326,7 +326,7 @@ jobs: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" diff --git a/.github/workflows/refresh-model-capabilities.yml b/.github/workflows/refresh-model-capabilities.yml index dd34e43ed..d21a2d661 100644 --- a/.github/workflows/refresh-model-capabilities.yml +++ b/.github/workflows/refresh-model-capabilities.yml @@ -21,7 +21,7 @@ jobs: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" diff --git a/.github/workflows/sisyphus-agent.yml b/.github/workflows/sisyphus-agent.yml index 7c83e3151..80e5af297 100644 --- a/.github/workflows/sisyphus-agent.yml +++ b/.github/workflows/sisyphus-agent.yml @@ -74,7 +74,7 @@ jobs: # Build local oh-my-opencode - name: Build oh-my-opencode run: | - bun install + bun install --frozen-lockfile bun run build # Install OpenCode + configure local plugin + auth in single step diff --git a/bun.lock b/bun.lock index a2dbefafc..a1086cc89 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@ast-grep/napi": "^0.41.1", "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/plugin": "^1.4.0", "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", @@ -30,17 +30,17 @@ "zod": "^4.3.0", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.17.15", - "oh-my-opencode-darwin-x64": "3.17.15", - "oh-my-opencode-darwin-x64-baseline": "3.17.15", - "oh-my-opencode-linux-arm64": "3.17.15", - "oh-my-opencode-linux-arm64-musl": "3.17.15", - "oh-my-opencode-linux-x64": "3.17.15", - "oh-my-opencode-linux-x64-baseline": "3.17.15", - "oh-my-opencode-linux-x64-musl": "3.17.15", - "oh-my-opencode-linux-x64-musl-baseline": "3.17.15", - "oh-my-opencode-windows-x64": "3.17.15", - "oh-my-opencode-windows-x64-baseline": "3.17.15", + "oh-my-opencode-darwin-arm64": "4.0.0", + "oh-my-opencode-darwin-x64": "4.0.0", + "oh-my-opencode-darwin-x64-baseline": "4.0.0", + "oh-my-opencode-linux-arm64": "4.0.0", + "oh-my-opencode-linux-arm64-musl": "4.0.0", + "oh-my-opencode-linux-x64": "4.0.0", + "oh-my-opencode-linux-x64-baseline": "4.0.0", + "oh-my-opencode-linux-x64-musl": "4.0.0", + "oh-my-opencode-linux-x64-musl-baseline": "4.0.0", + "oh-my-opencode-windows-x64": "4.0.0", + "oh-my-opencode-windows-x64-baseline": "4.0.0", }, "peerDependencies": { "zod": "^4.0.0", @@ -52,6 +52,13 @@ "@ast-grep/napi", "@code-yeongyu/comment-checker", ], + "overrides": { + "@hono/node-server": "^1.19.13", + "express-rate-limit": "^8.5.1", + "fast-uri": "^3.1.2", + "hono": "^4.12.18", + "path-to-regexp": "^8.4.2", + }, "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=="], @@ -95,9 +102,9 @@ "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.7.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-AOic1jPHY3CpNraOuO87YZHO3uRzm9eLd0wyYYN89/76Ugk2TfdUYJ6El/Oe8fzOnHKiOF0IfBeWRo0IUjrHHg=="], - "@hono/node-server": ["@hono/node-server@1.19.10", "", { "peerDependencies": { "hono": "^4" } }, "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "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-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@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=="], @@ -173,11 +180,11 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -197,7 +204,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -205,7 +212,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -241,27 +248,27 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.17.15", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-S0BpJVAwBcwSjd3Y5zE9mb6fKrRf2be1jIYnlifpbCyEI9yiludzuqQ9WKJstZQYD7HJpPlAMPIbJwojSl95sw=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.0.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-t7b65Oucpa5RrbRrkc37xlUu4jWPh3WgxSi5yhJlXHf0HuUIzmw4Lb+3Z/0ZzdcC/0pbdiPNnE7zxAlcZVZscQ=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.17.15", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-of8+u/jCobddh1aGTGugLyCcDtib76ZmzNuFEE7HT/G3pYthiJzxb9t2qPBKwbYmILNeJJjov7VL5XLD08IVnA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.0.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KySGNCvz1yUBKR9euzFgRdlqmc5VnhPxNl6dbVbNrjBkoXjqdDgWK4+FuTjRnaoEH8I5qkpLenmDVYDWHUuxzA=="], - "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.17.15", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Jc03G9drhyawG9GsAQO242Ct36qyn0LdJJuXHZ8ULYQ1+fsVFbr/h6OqHLh1JfKkzXRZngDHbyGJ0mqfZsXhmA=="], + "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.0.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BDzcEMxPCKD6/HIRBwCzQWUDgWLIXLjWazOVTYHb8ZsZS+UiFW9bPMs92Qj8NN8Nt1WzhTI91AxG1j+82MWdow=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.17.15", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-k0I8CH7UFVmJPA/qj95VVlQc4kRKGTho1Lm6sz1dI58GdzXesclGkBMkXyRH45cja8zDKKHdZJipcQTel/vbZw=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9CRfdF6ZDtBjTw2R1u6K12GVaUkWOrSavQ1+r2oHR05qzXKuFB9fL7cX6XFC1nJ0sdrzbPS7Wb95NTNT57f3sQ=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.17.15", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-I1wAoysz8E4Iym8wTFzue/hKFRD3QnkIyoHtB9j/4kFD+z5NzxDvBQ7q+beYjErGzyCC1qpM6/HIbBuVLfOvpQ=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DtASaFt42Q7cGWniR8zAiMy+Lw1LQa715HBA9IceuqX5V+T5zKB+CdjAw+/TH2RSDe05sp0Jvr8sCgSxPvH6hg=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.17.15", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-saXRRWHt3b9xJ3zELCvTxSR75j+nJ/ZkDukTNNWjodeOvESzxe9yc+7Oi7XRUOIGZ2zqtjTPVc8py15ifTW3mA=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-CNkI+nMK5tk94cDXSe9y5MZNTI/sSof2qGy69pUZz9kG6NMqXx6QeqCx36J66xacW3Oxa7Pvvcu+7heaNpL9Bw=="], - "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.17.15", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-a3QxM9w0UQ7Wk5CDWwKkerTLYlBVGbXDvIgKW+TDtMEfQWaSnKTT27mjv9o5E5PQKYxUhgZaMnIutzX/LuIsmw=="], + "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-rFpWgevtWNdflrTjcsFXUuELwKZGY6jOJ0HaXm1WZ406gswXfqR8VEUlrUYps6toycstZsdPuQXGSgbD8SfYKQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.17.15", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xrzbO5iThuox8jbJY3IBst5EO2BvmNtKE6ScgyE8EonSvsLa53l35MI0S4y5iS208LB4E3o1uZJFdK4c13xaPQ=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hyCZ9GCi2RiqI1gqH9S8VZPqDz+c0EUT6ne1spTZBMS6qseSPOIztn+ZoplBXAgsOvEGum45ydcYFjkvLaqNFA=="], - "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.17.15", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/bbQK5w2s4DVX6kzT6n670xR8sqaxr8TLBFJz1ZaygQ8S5kIo+owIM3CIOZG9HfuwUBznOhDnNvTbBkDzeZlIw=="], + "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-u8w5WP5eGH0kUnQKHJFgVzQaf/sE6cOctIDzEr04D2k2A3khRmItwaJ+0o84GeXTjCnXtAeX2SFn1hut+H6Rgg=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.17.15", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-+CoU4oWktRbzUusyAIQCIKprGNKmoZeziCqbPxGXgtjwyMy+1hy1K4Ow+v1bzCgrXNBbepKeDfKr7EoafxdHkQ=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.0.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-+aRlStPSkaHs9awlrhHQ4/em5SmyHQVbNY15y4dfs/OHKdFtDbFQDrS22dqqpBOHmucESf96mEzAs9RGfUvzOA=="], - "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.17.15", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-gvxS4ZpY5qPo0HdclInF0I3VZL3s88UUELXf2GVKbsY7OJ9kT+itB4OtNcWJBiP36dBQnAX7BZqEWRJk9iJwPA=="], + "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.0.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-VonuZat8PK3TKPufpjQsrkIy3L1+N5yVBl/na43n1E6GNrLe5Qv3TBqfDFewAS/Ogv2P3uh5FIn3v7DhF8Bh+A=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -271,7 +278,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], diff --git a/docs/guide/orchestration.md b/docs/guide/orchestration.md index dc50b10bd..e082025f8 100644 --- a/docs/guide/orchestration.md +++ b/docs/guide/orchestration.md @@ -63,7 +63,7 @@ flowchart TB Plan -->|"Read"| Orchestrator Orchestrator -->|"task(category=deep/quick/unspecified-*)"| Junior - Orchestrator -->|"call_omo_agent(subagent_type=oracle)"| Oracle + Orchestrator -->|"task(subagent_type=oracle)"| Oracle Orchestrator -->|"call_omo_agent(subagent_type=explore)"| Explore Orchestrator -->|"call_omo_agent(subagent_type=librarian)"| Librarian Orchestrator -->|"task(category=visual-engineering, load_skills=[frontend-ui-ux])"| Frontend diff --git a/package.json b/package.json index bc8dfaa6f..322dc9024 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "prepublishOnly": "bun run clean && bun run build", "test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail", "typecheck": "tsc --noEmit", - "test": "bun test bin script src" + "test": "bun run script/run-ci-tests.ts" }, "keywords": [ "opencode", @@ -60,7 +60,7 @@ "@ast-grep/napi": "^0.41.1", "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/plugin": "^1.4.0", "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", @@ -93,7 +93,13 @@ "oh-my-opencode-windows-x64": "4.0.0", "oh-my-opencode-windows-x64-baseline": "4.0.0" }, - "overrides": {}, + "overrides": { + "hono": "^4.12.18", + "@hono/node-server": "^1.19.13", + "express-rate-limit": "^8.5.1", + "fast-uri": "^3.1.2", + "path-to-regexp": "^8.4.2" + }, "trustedDependencies": [ "@ast-grep/cli", "@ast-grep/napi", diff --git a/script/run-ci-tests.test.ts b/script/run-ci-tests.test.ts new file mode 100644 index 000000000..22ad433b2 --- /dev/null +++ b/script/run-ci-tests.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" + +describe("test script isolation", () => { + test("#given mock.module tests in the suite #then bun run test uses the isolated CI runner", async () => { + //#given + const packageJson = await Bun.file("package.json").json() + + //#then + expect(packageJson.scripts.test).toBe("bun run script/run-ci-tests.ts") + }) +}) diff --git a/signatures/cla.json b/signatures/cla.json index d413c60ec..1146ba165 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3271,6 +3271,14 @@ "created_at": "2026-05-11T03:09:24Z", "repoId": 1108837393, "pullRequestNo": 3934 + }, + { + "name": "jas32096", + "id": 5062225, + "comment_id": 4427423011, + "created_at": "2026-05-12T04:48:14Z", + "repoId": 1108837393, + "pullRequestNo": 3966 } ] } \ No newline at end of file diff --git a/src/__tests__/perf/plugin-init-team-mode-resume-defer.test.ts b/src/__tests__/perf/plugin-init-team-mode-resume-defer.test.ts index 8f5d490a4..db37df00b 100644 --- a/src/__tests__/perf/plugin-init-team-mode-resume-defer.test.ts +++ b/src/__tests__/perf/plugin-init-team-mode-resume-defer.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { describe, expect, it } from "bun:test" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const HUNG_LEAD_SESSION_ID = "ses_999999999fffeeRegrTestHang0" @@ -16,11 +17,11 @@ function makeHangingClient(): { hangCount.value += 1 return new Promise(() => {}) } - const client = { + const client = unsafeTestValue({ session: { get: sessionGet, }, - } as unknown as PluginInput["client"] + }) return { hangCount, client } } diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index b744902f1..a2851d5fd 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -26,10 +26,6 @@ declare function clearTimeout(timeout: number): void type ProcessOutputStream = ReturnType["stdout"] -declare const Bun: { - readableStreamToText(stream: NonNullable): Promise -} - export interface BunInstallResult { success: boolean timedOut?: boolean @@ -50,7 +46,7 @@ function readProcessOutput(stream: ProcessOutputStream): Promise { return Promise.resolve("") } - return Bun.readableStreamToText(stream) + return new Response(stream).text() } function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void { diff --git a/src/cli/config-manager/npm-dist-tags.test.ts b/src/cli/config-manager/npm-dist-tags.test.ts index 3de417290..d98e4e4a1 100644 --- a/src/cli/config-manager/npm-dist-tags.test.ts +++ b/src/cli/config-manager/npm-dist-tags.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { fetchNpmDistTags } from "../config-manager" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("fetchNpmDistTags", () => { const originalFetch = globalThis.fetch @@ -13,12 +14,12 @@ describe("fetchNpmDistTags", () => { test("returns dist-tags on success", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), } as Response) - ) as unknown as typeof fetch + )) //#when const result = await fetchNpmDistTags("oh-my-openagent") @@ -29,7 +30,7 @@ describe("fetchNpmDistTags", () => { test("returns null on network failure", async () => { //#given - globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await fetchNpmDistTags("oh-my-openagent") @@ -40,12 +41,12 @@ describe("fetchNpmDistTags", () => { test("returns null on non-ok response", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: false, status: 404, } as Response) - ) as unknown as typeof fetch + )) //#when const result = await fetchNpmDistTags("oh-my-openagent") diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index 27171db5b..31e9afb91 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import * as configContext from "./config-context" import * as spawnHelpers from "../../shared/spawn-with-windows-hide" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type OpenCodeBinaryModule = typeof import("./opencode-binary") @@ -92,12 +93,12 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = ((handler: TimerHandler) => { + const immediateSetTimeout = unsafeTestValue(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return 1 as unknown as ReturnType - }) as unknown as typeof globalThis.setTimeout + return unsafeTestValue>(1) + })) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const result = await getOpenCodeVersion() @@ -124,12 +125,12 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = ((handler: TimerHandler) => { + const immediateSetTimeout = unsafeTestValue(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return 1 as unknown as ReturnType - }) as unknown as typeof globalThis.setTimeout + return unsafeTestValue>(1) + })) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const result = await getOpenCodeVersion() diff --git a/src/cli/config-manager/plugin-name-with-version.test.ts b/src/cli/config-manager/plugin-name-with-version.test.ts index 7da003338..d696efeaa 100644 --- a/src/cli/config-manager/plugin-name-with-version.test.ts +++ b/src/cli/config-manager/plugin-name-with-version.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { getPluginNameWithVersion } from "../config-manager" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("getPluginNameWithVersion", () => { const originalFetch = globalThis.fetch @@ -13,12 +14,12 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical latest tag when current version matches latest", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), } as Response) - ) as unknown as typeof fetch + )) //#when const result = await getPluginNameWithVersion("3.13.1") @@ -29,7 +30,7 @@ describe("getPluginNameWithVersion", () => { test("preserves the canonical prerelease channel when fetch fails", async () => { //#given - globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await getPluginNameWithVersion("3.14.0-beta.1") @@ -40,12 +41,12 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical bare package name for stable fallback", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: false, status: 404, } as Response) - ) as unknown as typeof fetch + )) //#when const result = await getPluginNameWithVersion("3.13.1") diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index 61bcf645f..d84dc7060 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { install } from "./install" import * as configManager from "./config-manager" import type { InstallArgs } from "./types" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" // Mock console methods to capture output const mockConsoleLog = mock(() => {}) @@ -57,12 +58,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), } as Response) - ) as unknown as typeof fetch + )) const args: InstallArgs = { tui: false, @@ -92,12 +93,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), } as Response) - ) as unknown as typeof fetch + )) const args: InstallArgs = { tui: false, @@ -131,12 +132,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), } as Response) - ) as unknown as typeof fetch + )) const args: InstallArgs = { tui: false, diff --git a/src/cli/run/completion-continuation.test.ts b/src/cli/run/completion-continuation.test.ts index 6fd553527..f707b08f4 100644 --- a/src/cli/run/completion-continuation.test.ts +++ b/src/cli/run/completion-continuation.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os" import type { RunContext } from "./types" import { _resetForTesting, setSessionAgent } from "../../features/claude-code-session-state" import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const testDirs: string[] = [] @@ -26,7 +27,7 @@ function createTempDir(): string { function createMockContext(directory: string): RunContext { return { - client: { + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock(() => Promise.resolve({ data: [] })), @@ -39,7 +40,7 @@ function createMockContext(directory: string): RunContext { })), messages: mock(async () => ({ data: [] })), }, - } as unknown as RunContext["client"], + }), sessionID: "test-session", directory, abortController: new AbortController(), @@ -155,17 +156,17 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "child-session" setSessionAgent("child-session", "atlas") - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "child-session" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "child-session" ? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -187,13 +188,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "lineage-only-session" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "lineage-only-session" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async () => ({ data: [] })) as unknown as RunContext["client"]["session"]["messages"] + }))) + ctx.client.session.messages = unsafeTestValue(mock(async () => ({ data: [] }))) const { checkCompletionConditions } = await import("./completion") @@ -218,17 +219,17 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "mismatch-subagent-session" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "mismatch-subagent-session" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "mismatch-subagent-session" ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -253,17 +254,17 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "appended-mismatch-session" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "appended-mismatch-session" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "appended-mismatch-session" ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -288,14 +289,14 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_appended_descendant" - ctx.client.session.get = mock(async () => { + ctx.client.session.get = unsafeTestValue(mock(async () => { throw new Error("session lookup failed") - }) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + })) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "ses_appended_descendant" ? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -317,12 +318,12 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_child" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "ses_direct_child" ? "ses_parent" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -347,12 +348,12 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_tracked" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -374,9 +375,9 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_unknown_child" - ctx.client.session.get = mock(async () => { + ctx.client.session.get = unsafeTestValue(mock(async () => { throw new Error("lineage unavailable") - }) as unknown as RunContext["client"]["session"]["get"] + })) const { checkCompletionConditions } = await import("./completion") @@ -401,17 +402,17 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_child" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "ses_direct_child" ? "ses_root_tracked" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "ses_direct_child" ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -437,20 +438,20 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_child_after_compaction" setSessionAgent("ses_child_after_compaction", "atlas") - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "ses_child_after_compaction" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "ses_child_after_compaction" ? [ { info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }, { info: { agent: "compaction", providerID: "openai", modelID: "gpt-5.4" } }, ] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -472,13 +473,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_sqlite_descendant" - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "ses_sqlite_descendant" ? "root-session" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ + }))) + ctx.client.session.messages = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: path.id === "ses_sqlite_descendant" ? [ { id: "msg_0001", info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4", time: { created: 100 } } }, @@ -486,7 +487,7 @@ describe("checkCompletionConditions continuation coverage", () => { { id: "msg_0002", info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4", time: { created: 100 } } }, ] : [], - })) as unknown as RunContext["client"]["session"]["messages"] + }))) const { checkCompletionConditions } = await import("./completion") @@ -512,13 +513,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_appended_child" setSessionAgent("ses_appended_child", "atlas") - ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: path.id === "ses_appended_child" ? "ses_root_tracked" : undefined, }, - })) as unknown as RunContext["client"]["session"]["get"] - ctx.client.session.messages = mock(async () => ({ data: [] })) as unknown as RunContext["client"]["session"]["messages"] + }))) + ctx.client.session.messages = unsafeTestValue(mock(async () => ({ data: [] }))) const { checkCompletionConditions } = await import("./completion") diff --git a/src/cli/run/completion-verbose-logging.test.ts b/src/cli/run/completion-verbose-logging.test.ts index ff9adfcf4..32ec7b2ce 100644 --- a/src/cli/run/completion-verbose-logging.test.ts +++ b/src/cli/run/completion-verbose-logging.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock, spyOn } from "bun:test" import type { RunContext, ChildSession, SessionStatus } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const createMockContext = (overrides: { childrenBySession?: Record @@ -13,7 +14,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock((opts: { path: { id: string } }) => @@ -21,7 +22,7 @@ const createMockContext = (overrides: { ), status: mock(() => Promise.resolve({ data: statuses })), }, - } as unknown as RunContext["client"], + }), sessionID: "test-session", directory: "/test", abortController: new AbortController(), diff --git a/src/cli/run/completion.test.ts b/src/cli/run/completion.test.ts index 1537d318d..e5ad120fa 100644 --- a/src/cli/run/completion.test.ts +++ b/src/cli/run/completion.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock, spyOn } from "bun:test" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const createMockContext = (overrides: { todo?: Todo[] @@ -13,7 +14,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => @@ -21,7 +22,7 @@ const createMockContext = (overrides: { ), status: mock(() => Promise.resolve({ data: statuses })), }, - } as unknown as RunContext["client"], + }), sessionID: "test-session", directory: "/test", abortController: new AbortController(), diff --git a/src/cli/run/event-handlers.test.ts b/src/cli/run/event-handlers.test.ts index b6687cf7d..a86ffceaa 100644 --- a/src/cli/run/event-handlers.test.ts +++ b/src/cli/run/event-handlers.test.ts @@ -2,6 +2,7 @@ const { describe, it, expect, spyOn } = require("bun:test") import type { RunContext } from "./types" import { createEventState } from "./events" import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const createMockContext = (sessionID: string = "test-session"): RunContext => ({ sessionID, @@ -23,7 +24,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with idle status - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, unsafeTestValue(payload), state) //#then - state.mainSessionIdle === true expect(state.mainSessionIdle).toBe(true) @@ -44,7 +45,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with busy status - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, unsafeTestValue(payload), state) //#then - state.mainSessionIdle === false expect(state.mainSessionIdle).toBe(false) @@ -65,7 +66,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with different session ID - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, unsafeTestValue(payload), state) //#then - state.mainSessionIdle remains unchanged expect(state.mainSessionIdle).toBe(true) @@ -86,7 +87,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with camelCase sessionId - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, unsafeTestValue(payload), state) //#then - state.mainSessionIdle === true expect(state.mainSessionIdle).toBe(true) @@ -114,7 +115,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -142,7 +143,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(false) @@ -170,7 +171,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.currentTool).toBe("read") @@ -200,7 +201,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.currentTool).toBeNull() @@ -225,7 +226,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -243,7 +244,7 @@ describe("handleMessagePartUpdated", () => { handleMessageUpdated( ctx, - { + unsafeTestValue({ type: "message.updated", properties: { info: { @@ -254,7 +255,7 @@ describe("handleMessagePartUpdated", () => { modelID: "claude-sonnet-4-6", }, }, - } as any, + }), state, ) state.messageStartedAtById["msg_1"] = 1000 @@ -262,7 +263,7 @@ describe("handleMessagePartUpdated", () => { // when handleMessagePartUpdated( ctx, - { + unsafeTestValue({ type: "message.part.updated", properties: { part: { @@ -274,13 +275,13 @@ describe("handleMessagePartUpdated", () => { time: { end: 1 }, }, }, - } as any, + }), state, ) handleMessagePartUpdated( ctx, - { + unsafeTestValue({ type: "message.part.updated", properties: { part: { @@ -292,7 +293,7 @@ describe("handleMessagePartUpdated", () => { time: { end: 2 }, }, }, - } as any, + }), state, ) @@ -323,7 +324,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, payload as any, state) + handleTuiToast(ctx, unsafeTestValue(payload), state) //#then expect(state.mainSessionError).toBe(true) @@ -344,7 +345,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, payload as any, state) + handleTuiToast(ctx, unsafeTestValue(payload), state) //#then expect(state.mainSessionError).toBe(false) diff --git a/src/cli/run/integration.test.ts b/src/cli/run/integration.test.ts index c2b019e62..7f46b8dc3 100644 --- a/src/cli/run/integration.test.ts +++ b/src/cli/run/integration.test.ts @@ -7,6 +7,7 @@ import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hid import type { OpencodeClient } from "./types" import * as originalSdk from "@opencode-ai/sdk" import * as originalPortUtils from "../../shared/port-utils" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const mockServerClose = mock(() => {}) const mockCreateOpencode = mock(() => @@ -56,14 +57,14 @@ function createMockWriteStream(): MockWriteStream { const createMockClient = ( getResult?: { error?: unknown; data?: { id: string } } -): OpencodeClient => ({ +): OpencodeClient => (unsafeTestValue({ session: { get: mock((opts: { path: { id: string } }) => Promise.resolve(getResult ?? { data: { id: opts.path.id } }) ), create: mock(() => Promise.resolve({ data: { id: "new-session-id" } })), }, -} as unknown as OpencodeClient) +})) describe("integration: --json mode", () => { it("emits valid RunResult JSON to stdout", () => { @@ -78,8 +79,8 @@ describe("integration: --json mode", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when @@ -103,8 +104,8 @@ describe("integration: --json mode", () => { const mockStdout = createMockWriteStream() const mockStderr = createMockWriteStream() const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -272,8 +273,8 @@ describe("integration: option combinations", () => { summary: "Test completed", } const jsonManager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) jsonManager.redirectToStderr() spawnSpy.mockClear() diff --git a/src/cli/run/json-output.test.ts b/src/cli/run/json-output.test.ts index d932af3c5..057a90fa4 100644 --- a/src/cli/run/json-output.test.ts +++ b/src/cli/run/json-output.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from "bun:test" import type { RunResult } from "./types" import { createJsonOutputManager } from "./json-output" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" interface MockWriteStream { write: (chunk: string) => boolean @@ -31,8 +32,8 @@ describe("createJsonOutputManager", () => { it("causes stdout writes to go to stderr", () => { // given const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -49,8 +50,8 @@ describe("createJsonOutputManager", () => { it("reverses the redirect", () => { // given const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -75,8 +76,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when @@ -98,8 +99,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when @@ -126,8 +127,8 @@ describe("createJsonOutputManager", () => { summary: "Test", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -148,8 +149,8 @@ describe("createJsonOutputManager", () => { it("work correctly", () => { // given const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when diff --git a/src/cli/run/poll-for-completion.test.ts b/src/cli/run/poll-for-completion.test.ts index 670c6ba05..1d02dd7ee 100644 --- a/src/cli/run/poll-for-completion.test.ts +++ b/src/cli/run/poll-for-completion.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:te import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import { createEventState } from "./events" import { pollForCompletion } from "./poll-for-completion" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const createMockContext = (overrides: { todo?: Todo[] @@ -15,7 +16,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => @@ -23,7 +24,7 @@ const createMockContext = (overrides: { ), status: mock(() => Promise.resolve({ data: statuses })), }, - } as unknown as RunContext["client"], + }), sessionID: "test-session", directory: "/test", abortController: new AbortController(), @@ -124,7 +125,7 @@ describe("pollForCompletion", () => { let todoCallCount = 0 let busyInserted = false - ;(ctx.client.session as any).todo = mock(async () => { + ;(unsafeTestValue(ctx.client.session)).todo = mock(async () => { todoCallCount++ if (todoCallCount === 1 && !busyInserted) { busyInserted = true @@ -133,10 +134,10 @@ describe("pollForCompletion", () => { } return { data: [] } }) - ;(ctx.client.session as any).children = mock(() => + ;(unsafeTestValue(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(ctx.client.session as any).status = mock(() => + ;(unsafeTestValue(ctx.client.session)).status = mock(() => Promise.resolve({ data: {} }) ) @@ -322,17 +323,17 @@ describe("pollForCompletion", () => { const abortController = new AbortController() let pollTick = 0 - ;(ctx.client.session as any).todo = mock(async () => { + ;(unsafeTestValue(ctx.client.session)).todo = mock(async () => { pollTick++ if (pollTick === 2) { eventState.currentTool = "task" } return { data: [] } }) - ;(ctx.client.session as any).children = mock(() => + ;(unsafeTestValue(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(ctx.client.session as any).status = mock(() => + ;(unsafeTestValue(ctx.client.session)).status = mock(() => Promise.resolve({ data: {} }) ) diff --git a/src/cli/run/session-resolver.test.ts b/src/cli/run/session-resolver.test.ts index 7b4338f11..4c1265a85 100644 --- a/src/cli/run/session-resolver.test.ts +++ b/src/cli/run/session-resolver.test.ts @@ -1,4 +1,5 @@ /// +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { resolveSession } from "./session-resolver"; @@ -10,7 +11,7 @@ const createMockClient = (overrides: { } = {}): OpencodeClient => { const { getResult, createResults = [] } = overrides let createCallIndex = 0 - return { + return unsafeTestValue({ session: { get: mock((opts: { path: { id: string } }) => Promise.resolve(getResult ?? { data: { id: opts.path.id } }) @@ -22,7 +23,7 @@ const createMockClient = (overrides: { return Promise.resolve(result) }), }, - } as unknown as OpencodeClient + }) } describe("resolveSession", () => { diff --git a/src/cli/run/timestamp-output.test.ts b/src/cli/run/timestamp-output.test.ts index 48b8a02bb..f3b1144eb 100644 --- a/src/cli/run/timestamp-output.test.ts +++ b/src/cli/run/timestamp-output.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createLocalDate(hours: number, minutes: number, seconds: number): Date { return new Date(2026, 1, 19, hours, minutes, seconds) @@ -87,7 +88,7 @@ describe("createTimestampedStdoutController", () => { it("prefixes stdout writes when enabled", () => { // given const stdout = createMockWriteStream() - const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + const controller = createTimestampedStdoutController(unsafeTestValue(stdout)) // when controller.enable() @@ -101,7 +102,7 @@ describe("createTimestampedStdoutController", () => { it("restores original write function", () => { // given const stdout = createMockWriteStream() - const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + const controller = createTimestampedStdoutController(unsafeTestValue(stdout)) controller.enable() // when @@ -118,7 +119,7 @@ describe("createTimestampedStdoutController", () => { it("supports Uint8Array chunks and encoding", () => { // given const stdout = createMockWriteStream() - const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + const controller = createTimestampedStdoutController(unsafeTestValue(stdout)) // when controller.enable() diff --git a/src/features/background-agent/background-task-notification-template.test.ts b/src/features/background-agent/background-task-notification-template.test.ts index 7001aef12..42c5371d0 100644 --- a/src/features/background-agent/background-task-notification-template.test.ts +++ b/src/features/background-agent/background-task-notification-template.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("buildBackgroundTaskNotificationText", () => { describe("#given one task still running after a completed task notification", () => { @@ -134,7 +135,7 @@ Use \`background_output(task_id="")\` to retrieve each result. const notification = buildBackgroundTaskNotificationText({ task: { id: "bg_abc123", - description: undefined as unknown as string, + description: unsafeTestValue(undefined), status: "completed", }, duration: "5s", @@ -142,8 +143,8 @@ Use \`background_output(task_id="")\` to retrieve each result. allComplete: true, remainingCount: 0, completedTasks: [ - { id: "bg_abc123", description: undefined as unknown as string, status: "completed" }, - { id: "bg_def456", description: undefined as unknown as string, status: "completed" }, + { id: "bg_abc123", description: unsafeTestValue(undefined), status: "completed" }, + { id: "bg_def456", description: unsafeTestValue(undefined), status: "completed" }, ], }) @@ -230,7 +231,7 @@ Use \`background_output(task_id="")\` to retrieve each result. const notification = buildBackgroundTaskNotificationText({ task: { id: "bg_xyz789", - description: undefined as unknown as string, + description: unsafeTestValue(undefined), status: "completed", }, duration: "3s", 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 4ad9a33cf..8c77654cf 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.test.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.test.ts @@ -12,6 +12,7 @@ import { setCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" import { getCompactionPartStorageDir } from "../../shared/compaction-marker" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("isCompactionAgent", () => { describe("#given agent name variations", () => { @@ -49,7 +50,7 @@ describe("isCompactionAgent", () => { test("returns false for null", () => { // when - const result = isCompactionAgent(null as unknown as string) + const result = isCompactionAgent(unsafeTestValue(null)) // then expect(result).toBe(false) diff --git a/src/features/background-agent/manager-circuit-breaker.test.ts b/src/features/background-agent/manager-circuit-breaker.test.ts index 8adef0618..1df525814 100644 --- a/src/features/background-agent/manager-circuit-breaker.test.ts +++ b/src/features/background-agent/manager-circuit-breaker.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os" import type { BackgroundTaskConfig } from "../../config/schema" import { BackgroundManager } from "./manager" import type { BackgroundTask } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createManager(config?: BackgroundTaskConfig): BackgroundManager { const client = { @@ -16,12 +17,12 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager { }, } - const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: config }) - const testManager = manager as unknown as { + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory: tmpdir() }), config: config }) + const testManager = unsafeTestValue<{ enqueueNotificationForParent: (sessionId: string, fn: () => Promise) => Promise notifyParentSession: (task: BackgroundTask) => Promise tasks: Map - } + }>(manager) testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => { await fn() @@ -32,7 +33,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager { } function getTaskMap(manager: BackgroundManager): Map { - return (manager as unknown as { tasks: Map }).tasks + return (unsafeTestValue<{ tasks: Map }>(manager)).tasks } async function flushAsyncWork() { diff --git a/src/features/background-agent/manager-session-permission.test.ts b/src/features/background-agent/manager-session-permission.test.ts index a9f4a1756..b50687442 100644 --- a/src/features/background-agent/manager-session-permission.test.ts +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -4,8 +4,41 @@ import { tmpdir } from "node:os" import type { PluginInput } from "@opencode-ai/plugin" import { BackgroundManager } from "./manager" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("BackgroundManager session permission", () => { + test("passes parent directory route when prompting the child session", async () => { + // given + const promptCalls: Array> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async () => ({ data: { id: "ses_child" } }), + promptAsync: async (input: Record) => { + promptCalls.push(input) + return {} + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory: tmpdir() }) }) + + // when + await manager.launch({ + description: "Test task", + prompt: "Do something", + agent: "explore", + parentSessionId: "ses_parent", + parentMessageId: "msg_parent", + }) + await new Promise(resolve => setTimeout(resolve, 50)) + manager.shutdown() + + // then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent" }) + }) + test("passes query directory when loading the parent session", async () => { // given const getCalls: Array> = [] @@ -21,7 +54,7 @@ describe("BackgroundManager session permission", () => { }, } const directory = tmpdir() - const manager = new BackgroundManager({ pluginContext: { client, directory } as unknown as PluginInput }) + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory }) }) // when await manager.launch({ @@ -62,7 +95,7 @@ describe("BackgroundManager session permission", () => { abort: async () => ({}), }, } - const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput }) + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory: tmpdir() }) }) // when await manager.launch({ diff --git a/src/features/background-agent/manager.polling.test.ts b/src/features/background-agent/manager.polling.test.ts index 3f6eb759d..bbd84d41c 100644 --- a/src/features/background-agent/manager.polling.test.ts +++ b/src/features/background-agent/manager.polling.test.ts @@ -345,6 +345,47 @@ describe("BackgroundManager pollRunningTasks", () => { expect(task.status).toBe("completed") expect(todoCallCount).toBe(0) }) + + test("#when cached incomplete todos become complete before idle polling #then refreshes todos and completes", async () => { + //#given + let todoCallCount = 0 + const manager = createManagerWithClient({ + status: async () => ({ data: { "ses-idle-stale-todos": { type: "idle" } } }), + todo: async () => { + todoCallCount += 1 + return { + data: [ + { content: "compile result", status: "completed", priority: "high" }, + ], + } + }, + }) + const task = createRunningTask("ses-idle-stale-todos") + injectTask(manager, task) + + manager.handleEvent({ + type: "message.part.updated", + properties: { sessionID: "ses-idle-stale-todos", type: "text" }, + }) + manager.handleEvent({ + type: "todo.updated", + properties: { + sessionID: "ses-idle-stale-todos", + todos: [ + { content: "compile result", status: "in_progress", priority: "high" }, + ], + }, + }) + + //#when + const poll = manager["pollRunningTasks"] + await poll.call(manager) + manager.shutdown() + + //#then + expect(task.status).toBe("completed") + expect(todoCallCount).toBe(1) + }) }) describe("#given a running task whose session status is busy", () => { diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9249af41a..c28abfcad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("completes task when session.idle carries session id in info", async () => { + //#given + const sessionID = "ses-info-idle-completes-task" + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + messages: async () => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "done" }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + } + + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + + const task = createMockTask({ + id: "task-info-idle-completes", + sessionId: sessionID, + parentSessionId: "parent-session", + parentMessageId: "msg-info-idle", + description: "task completed by nested idle event", + agent: "explore", + status: "running", + startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)), + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.idle", + properties: { info: { id: sessionID } }, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + //#then + expect(task.status).toBe("completed") + + manager.shutdown() + }) + test("completes task on session.status idle after todo-continuation finishes", async () => { //#given const sessionID = "ses-status-idle-after-todo-continuation" @@ -5747,6 +5795,54 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => { expect(task.progress!.toolCalls).toBe(2) }) + test("should update lastUpdate when legacy message.part.updated only has part session id", () => { + //#given - a running task with stale lastUpdate + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + + const oldUpdate = new Date(Date.now() - 300_000) + const task: BackgroundTask = { + id: "task-part-only-1", + sessionId: "session-part-only-1", + parentSessionId: "parent-1", + parentMessageId: "msg-1", + description: "Legacy part-only task", + prompt: "Keep working", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 0, + lastUpdate: oldUpdate, + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a legacy message.part.updated event arrives without top-level sessionID + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "session-part-only-1", + type: "text", + text: "still working", + }, + }, + }) + + //#then - lastUpdate should be refreshed, toolCalls should remain 0 + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime()) + expect(task.progress!.toolCalls).toBe(0) + }) + test("should update lastUpdate on thinking-type message.part.updated event", () => { //#given - a running task with stale lastUpdate const client = { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 3ec161b0e..ae963a28d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -14,10 +14,13 @@ import { getAgentToolRestrictions, normalizePromptTools, normalizeSDKResponse, - promptWithModelSuggestionRetry, resolveInheritedPromptTools, createInternalAgentTextPart, + messagesInDirectory, + promptAsyncInDirectory, + promptWithRetryInDirectory, } from "../../shared" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" @@ -88,8 +91,6 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" -import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" - type OpencodeClient = PluginInput["client"] type ParentWakePromptContext = { @@ -99,15 +100,6 @@ type ParentWakePromptContext = { tools?: Record } -type PendingParentWake = { - promptContext: ParentWakePromptContext - notifications: string[] -} - -type SessionStatusInfo = { type?: string } - -const PENDING_PARENT_WAKE_RETRY_MS = 1_000 - interface MessagePartInfo { id?: string sessionID?: string @@ -118,7 +110,7 @@ interface MessagePartInfo { interface EventProperties { sessionID?: string - info?: { id?: string } + info?: { id?: string; sessionID?: string } [key: string]: unknown } @@ -229,8 +221,6 @@ export class BackgroundManager { private completedTaskSummaries: Map = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() - private pendingParentWakes: Map = new Map() - private pendingParentWakeTimers: Map> = new Map() private observedOutputSessions: Set = new Set() private observedIncompleteTodosBySession: Map = new Map() private rootDescendantCounts: Map @@ -792,10 +782,10 @@ The fallback retry session is now created and can be inspected directly. parts: [createInternalAgentTextPart(input.prompt)], } - promptWithModelSuggestionRetry(this.client, { + promptWithRetryInDirectory(this.client, { path: { id: sessionID }, body: promptBody, - }).catch(async (error) => { + }, parentDirectory).catch(async (error) => { // Retry with fallback agent if the original agent was unregistered (e.g., after a model switch) if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { log("[background-agent] Agent not found, retrying with fallback agent", { @@ -808,10 +798,10 @@ The fallback retry session is now created and can be inspected directly. includeTeamToolDenylist: input.teamRunId === undefined, }) setSessionTools(sessionID, fallbackBody.tools as Record) - await promptWithModelSuggestionRetry(this.client, { + await promptWithRetryInDirectory(this.client, { path: { id: sessionID }, body: fallbackBody, - }) + }, parentDirectory) task.agent = FALLBACK_AGENT return } catch (retryError) { @@ -1156,7 +1146,7 @@ The fallback retry session is now created and can be inspected directly. applySessionPromptParams(existingTask.sessionId!, existingTask.model) } - this.client.session.promptAsync({ + promptAsyncInDirectory(this.client, { path: { id: existingTask.sessionId }, body: { agent: existingTask.agent, @@ -1176,7 +1166,7 @@ The fallback retry session is now created and can be inspected directly. })(), parts: [createInternalAgentTextPart(input.prompt)], }, - }).catch(async (error) => { + }, this.directory).catch(async (error) => { log("[background-agent] resume prompt error:", error) const errorInfo = { name: extractErrorName(error), @@ -1219,8 +1209,8 @@ The fallback retry session is now created and can be inspected directly. private async checkSessionTodos(sessionID: string): Promise { const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID) - if (observedIncompleteTodos !== undefined) { - return observedIncompleteTodos + if (observedIncompleteTodos === false) { + return false } try { @@ -1260,8 +1250,9 @@ The fallback retry session is now created and can be inspected directly. this.observedIncompleteTodosBySession.delete(sessionID) } - private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean { - if (!partInfo?.sessionID) return false + private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean { + if (!partInfo) return false + if (!partInfo.sessionID && !sessionID) return false if (partInfo.tool) return true if (partInfo.type === "tool" || partInfo.type === "tool_result") return true if (partInfo.type === "text" || partInfo.type === "reasoning") return true @@ -1279,9 +1270,9 @@ The fallback retry session is now created and can be inspected directly. const info = props?.info if (!info || typeof info !== "object") return - const sessionID = (info as Record)["sessionID"] + const sessionID = resolveMessageEventSessionID(props) const role = (info as Record)["role"] - if (typeof sessionID !== "string") return + if (!sessionID) return if (role === "tool") { this.markSessionOutputObserved(sessionID) @@ -1312,7 +1303,7 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "message.part.updated" || event.type === "message.part.delta") { const partInfo = resolveMessagePartInfo(props) - const sessionID = partInfo?.sessionID + const sessionID = resolveMessageEventSessionID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1320,7 +1311,7 @@ The fallback retry session is now created and can be inspected directly. const { task } = resolved - if (this.hasOutputSignalFromPart(partInfo)) { + if (this.hasOutputSignalFromPart(partInfo, sessionID)) { this.markSessionOutputObserved(sessionID) } @@ -1404,7 +1395,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "todo.updated") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) const todos = Array.isArray(props?.todos) ? props.todos : undefined if (!sessionID || !todos) return @@ -1419,12 +1410,6 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "session.idle") { if (!props || typeof props !== "object") return - const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined - if (sessionID) { - void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { - log("[background-agent] Failed to flush pending parent wake:", { sessionID, error }) - }) - } handleSessionIdleBackgroundEvent({ properties: props as Record, findBySession: (id) => { @@ -1440,7 +1425,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.error") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1469,9 +1454,8 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.deleted") { - const info = props?.info - if (!info || typeof info.id !== "string") return - const sessionID = info.id + const sessionID = resolveSessionEventID(props) + if (!sessionID) return this.clearSessionOutputObserved(sessionID) this.clearSessionTodoObservation(sessionID) @@ -1529,7 +1513,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.status") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const status = props?.status as { type?: string; message?: string } | undefined if (!sessionID || !status?.type) return @@ -1758,9 +1742,9 @@ The task was re-queued on a fallback model after a retryable failure. } try { - const response = await this.client.session.messages({ + const response = await messagesInDirectory(this.client, { path: { id: sessionID }, - }) + }, this.directory) const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true }) @@ -2169,7 +2153,9 @@ The task was re-queued on a fallback model after a retryable failure. if (this.enableParentSessionNotifications) { try { - const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionId } }) + const messagesResp = await messagesInDirectory(this.client, { + path: { id: task.parentSessionId }, + }, this.directory) const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string @@ -2230,42 +2216,30 @@ The task was re-queued on a fallback model after a retryable failure. ...(variant !== undefined ? { variant } : {}), ...(resolvedTools ? { tools: resolvedTools } : {}), } - const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId) - - if (shouldDeferReply) { - this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext) - log("[background-agent] Deferred notification until parent session is idle:", { + try { + await promptAsyncInDirectory(this.client, { + path: { id: task.parentSessionId }, + body: { + noReply: !shouldReply, + ...parentPromptContext, + parts: [createInternalAgentTextPart(notification)], + }, + }, this.directory) + log("[background-agent] Sent notification to parent session:", { taskId: task.id, allComplete, isTaskFailure, + noReply: !shouldReply, }) - } else { - try { - await this.client.session.promptAsync({ - path: { id: task.parentSessionId }, - body: { - noReply: !shouldReply, - ...parentPromptContext, - parts: [createInternalAgentTextPart(notification)], - }, - }) - log("[background-agent] Sent notification to parent session:", { + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { taskId: task.id, - allComplete, - isTaskFailure, - noReply: !shouldReply, - deferredReply: false, + parentSessionID: task.parentSessionId, }) - } catch (error) { - if (isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { - taskId: task.id, - parentSessionID: task.parentSessionId, - }) - this.queuePendingNotification(task.parentSessionId, notification) - } else { - log("[background-agent] Failed to send notification:", error) - } + this.queuePendingNotification(task.parentSessionId, notification) + } else { + log("[background-agent] Failed to send notification:", error) } } } else { @@ -2287,112 +2261,6 @@ The task was re-queued on a fallback model after a retryable failure. return false } - private async isSessionActive(sessionID: string): Promise { - const sessionStatusMethod = this.client?.session?.status - if (typeof sessionStatusMethod !== "function") { - return false - } - - try { - const statusResult = await this.client.session.status() - const statuses = normalizeSDKResponse( - statusResult, - {} as Record, - ) - const status = statuses[sessionID] - return typeof status?.type === "string" && isActiveSessionStatus(status.type) - } catch (error) { - log("[background-agent] Unable to check parent session status before wake:", { - sessionID, - error, - }) - return false - } - } - - private queuePendingParentWake( - sessionID: string, - notification: string, - promptContext: ParentWakePromptContext, - ): void { - const pendingWake = this.pendingParentWakes.get(sessionID) - if (pendingWake) { - pendingWake.notifications.push(notification) - pendingWake.promptContext = promptContext - } else { - this.pendingParentWakes.set(sessionID, { - promptContext, - notifications: [notification], - }) - } - this.schedulePendingParentWakeFlush(sessionID) - } - - private async flushPendingParentWake(sessionID: string): Promise { - const pendingWake = this.pendingParentWakes.get(sessionID) - if (!pendingWake) { - this.clearPendingParentWakeTimer(sessionID) - return - } - - if (await this.isSessionActive(sessionID)) { - this.schedulePendingParentWakeFlush(sessionID) - return - } - - this.pendingParentWakes.delete(sessionID) - this.clearPendingParentWakeTimer(sessionID) - await settleAfterSessionIdle() - - if (await this.isSessionActive(sessionID)) { - this.pendingParentWakes.set(sessionID, pendingWake) - this.schedulePendingParentWakeFlush(sessionID) - return - } - - const notificationContent = pendingWake.notifications.join("\n\n") - - try { - await this.client.session.promptAsync({ - path: { id: sessionID }, - body: { - noReply: false, - ...pendingWake.promptContext, - parts: [createInternalAgentTextPart(notificationContent)], - }, - }) - log("[background-agent] Sent deferred parent wake:", { sessionID }) - } catch (error) { - this.queuePendingNotification(sessionID, notificationContent) - log("[background-agent] Failed to send deferred parent wake:", { sessionID, error }) - } - } - - private schedulePendingParentWakeFlush(sessionID: string): void { - if (this.pendingParentWakeTimers.has(sessionID)) { - return - } - - const timer = setTimeout(() => { - this.pendingParentWakeTimers.delete(sessionID) - void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { - log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) - }) - }, PENDING_PARENT_WAKE_RETRY_MS) - - this.pendingParentWakeTimers.set(sessionID, timer) - } - - private clearPendingParentWakeTimer(sessionID: string): void { - const timer = this.pendingParentWakeTimers.get(sessionID) - if (!timer) { - return - } - - clearTimeout(timer) - this.pendingParentWakeTimers.delete(sessionID) - } - private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void { pruneStaleTasksAndNotifications({ tasks: this.tasks, @@ -2706,11 +2574,6 @@ The task was re-queued on a fallback model after a retryable failure. } this.idleDeferralTimers.clear() - for (const timer of this.pendingParentWakeTimers.values()) { - clearTimeout(timer) - } - this.pendingParentWakeTimers.clear() - for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) SessionCategoryRegistry.remove(sessionID) @@ -2723,7 +2586,6 @@ The task was re-queued on a fallback model after a retryable failure. this.pendingNotifications.clear() this.pendingByParent.clear() this.notificationQueueByParent.clear() - this.pendingParentWakes.clear() this.rootDescendantCounts.clear() this.queuesByKey.clear() this.processingKeys.clear() diff --git a/src/features/background-agent/session-existence.test.ts b/src/features/background-agent/session-existence.test.ts index 9b59a4816..f0cdbed52 100644 --- a/src/features/background-agent/session-existence.test.ts +++ b/src/features/background-agent/session-existence.test.ts @@ -2,16 +2,17 @@ import { describe, expect, mock, test } from "bun:test" import type { OpencodeClient } from "./opencode-client" import { verifySessionExists } from "./session-existence" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("verifySessionExists", () => { test("passes query directory to session lookup when provided", async () => { // given const get = mock(async () => ({ data: { id: "session-123" } })) - const client = { + const client = unsafeTestValue({ session: { get, }, - } as unknown as OpencodeClient + }) // when const result = await verifySessionExists(client, "session-123", "/project/root") diff --git a/src/features/background-agent/session-idle-event-handler.ts b/src/features/background-agent/session-idle-event-handler.ts index c3396f75d..e004b128d 100644 --- a/src/features/background-agent/session-idle-event-handler.ts +++ b/src/features/background-agent/session-idle-event-handler.ts @@ -1,12 +1,8 @@ import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { MIN_IDLE_TIME_MS } from "./constants" import type { BackgroundTask } from "./types" -function getString(obj: Record, key: string): string | undefined { - const value = obj[key] - return typeof value === "string" ? value : undefined -} - export function handleSessionIdleBackgroundEvent(args: { properties: Record findBySession: (sessionID: string) => BackgroundTask | undefined @@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: { emitIdleEvent, } = args - const sessionID = getString(properties, "sessionID") + const sessionID = resolveSessionEventID(properties) if (!sessionID) return const task = findBySession(sessionID) diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index dc0ed17d0..f19c6ea7b 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -535,6 +535,58 @@ describe("background-agent spawner fallback model promotion", () => { ]) }) + test("passes parent directory route when prompting the child session", async () => { + // given + const promptCalls: Array> = [] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: "ses_child_query" } }), + promptAsync: async (input: Record) => { + promptCalls.push(input) + return {} + }, + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "sisyphus-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, + }, + } + + // when + await startTask(item as never, { + client: client as never, + directory: "/fallback", + concurrencyManager: { release: () => {} } as never, + tmuxEnabled: false, + onTaskError: () => {}, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent/dir" }) + }) + test("strips leading zwsp from prompt body agent before promptAsync", async () => { //#given const promptCalls: Array<{ body?: { agent?: string } }> = [] diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index bfbe675da..7be63ede1 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -1,6 +1,6 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared" +import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" @@ -171,10 +171,10 @@ export async function startTask( } // Must fire BEFORE tmux callback: attach client needs session activity to render TUI. - const promptChain = promptWithModelSuggestionRetry(client, { + const promptChain = promptWithRetryInDirectory(client, { path: { id: sessionID }, body: promptBody, - }).catch(async (error) => { + }, parentDirectory).catch(async (error) => { if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { log("[background-agent] Agent not found, retrying with fallback agent", { original: input.agent, @@ -182,12 +182,12 @@ export async function startTask( taskId: task.id, }) try { - await promptWithModelSuggestionRetry(client, { + await promptWithRetryInDirectory(client, { path: { id: sessionID }, body: buildFallbackBody(promptBody, FALLBACK_AGENT, { includeTeamToolDenylist: input.teamRunId === undefined, }), - }) + }, parentDirectory) task.agent = FALLBACK_AGENT return } catch (retryError) { @@ -227,18 +227,19 @@ export async function startTask( export async function resumeTask( task: BackgroundTask, input: ResumeInput, - ctx: Pick + ctx: Pick ): Promise { - const { client, concurrencyManager, onTaskError } = ctx + const { client, concurrencyManager, directory, onTaskError } = ctx if (!task.sessionId) { throw new Error(`Task has no sessionID: ${task.id}`) } + const sessionID = task.sessionId if (task.status === "running") { log("[background-agent] Resume skipped - task already running:", { taskId: task.id, - sessionID: task.sessionId, + sessionID, }) return } @@ -262,7 +263,7 @@ export async function resumeTask( lastUpdate: new Date(), } - subagentSessions.add(task.sessionId) + subagentSessions.add(sessionID) const toastManager = getTaskToastManager() if (toastManager) { @@ -274,10 +275,10 @@ export async function resumeTask( }) } - log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionId }) + log("[background-agent] Resuming task:", { taskId: task.id, sessionID }) log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { - sessionID: task.sessionId, + sessionID, agent: task.agent, model: task.model, promptLength: input.prompt.length, @@ -291,7 +292,7 @@ export async function resumeTask( : undefined const resumeVariant = task.model?.variant - applySessionPromptParams(task.sessionId, task.model) + applySessionPromptParams(sessionID, task.model) const resumeBody = { agent: task.agent, @@ -308,10 +309,10 @@ export async function resumeTask( parts: [createInternalAgentTextPart(input.prompt)], } - client.session.promptAsync({ - path: { id: task.sessionId }, + promptWithRetryInDirectory(client, { + path: { id: sessionID }, body: resumeBody, - }).catch(async (error) => { + }, directory).catch(async (error) => { if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) { log("[background-agent] Resume agent not found, retrying with fallback agent", { original: task.agent, @@ -319,12 +320,12 @@ export async function resumeTask( taskId: task.id, }) try { - await promptWithModelSuggestionRetry(client, { - path: { id: task.sessionId! }, + await promptWithRetryInDirectory(client, { + path: { id: sessionID }, body: buildFallbackBody(resumeBody, FALLBACK_AGENT, { includeTeamToolDenylist: task.teamRunId === undefined, }), - }) + }, directory) task.agent = FALLBACK_AGENT return } catch (retryError) { diff --git a/src/features/background-agent/subagent-spawn-limits.test.ts b/src/features/background-agent/subagent-spawn-limits.test.ts index e3094c551..8af9b6ff9 100644 --- a/src/features/background-agent/subagent-spawn-limits.test.ts +++ b/src/features/background-agent/subagent-spawn-limits.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_MAX_SUBAGENT_DEPTH, createSubagentDepthLimitError, } from "./subagent-spawn-limits" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient { return { @@ -20,14 +21,14 @@ describe("resolveSubagentSpawnContext", () => { test("passes query.directory to each session.get call", async () => { // given const sessionGetCalls: Array> = [] - const client = createMockClient((async (input) => { + const client = createMockClient(unsafeTestValue((async (input) => { sessionGetCalls.push(input as Record) if (input.path.id === "child-session") { return { data: { id: "child-session", parentID: "root-session" } } } return { data: { id: "root-session", parentID: undefined } } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when const result = await resolveSubagentSpawnContext(client, "child-session", "/project/root") @@ -50,10 +51,10 @@ describe("resolveSubagentSpawnContext", () => { describe("#given session.get returns an SDK error response", () => { test("throws a fail-closed spawn blocked error", async () => { // given - const client = createMockClient((async () => ({ + const client = createMockClient(unsafeTestValue((async () => ({ error: "lookup failed", data: undefined, - })) as unknown as OpencodeClient["session"]["get"]) + })))) // when const result = resolveSubagentSpawnContext(client, "parent-session") @@ -66,9 +67,9 @@ describe("resolveSubagentSpawnContext", () => { describe("#given session.get returns no session data", () => { test("throws a fail-closed spawn blocked error", async () => { // given - const client = createMockClient((async () => ({ + const client = createMockClient(unsafeTestValue((async () => ({ data: undefined, - })) as unknown as OpencodeClient["session"]["get"]) + })))) // when const result = resolveSubagentSpawnContext(client, "parent-session") @@ -81,12 +82,12 @@ describe("resolveSubagentSpawnContext", () => { 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) => { + const client = createMockClient(unsafeTestValue((async (opts) => { if (opts.path.id === "root-session") { return { data: { id: "root-session", parentID: undefined } } } return { error: "not found", data: undefined } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when const result = await resolveSubagentSpawnContext(client, "root-session") @@ -99,7 +100,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-1 child reports childDepth 2", async () => { // given - child -> root chain - const client = createMockClient((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { if (opts.path.id === "child-1") { return { data: { id: "child-1", parentID: "root-session" } } } @@ -107,7 +108,7 @@ describe("resolveSubagentSpawnContext", () => { return { data: { id: "root-session", parentID: undefined } } } return { error: "not found", data: undefined } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when const result = await resolveSubagentSpawnContext(client, "child-1") @@ -120,7 +121,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-2 grandchild reports childDepth 3", async () => { // given - grandchild -> child -> root chain - const client = createMockClient((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { const sessions: Record = { "grandchild": { id: "grandchild", parentID: "child" }, "child": { id: "child", parentID: "root" }, @@ -129,7 +130,7 @@ describe("resolveSubagentSpawnContext", () => { const session = sessions[opts.path.id] if (session) return { data: session } return { error: "not found", data: undefined } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when const result = await resolveSubagentSpawnContext(client, "grandchild") @@ -153,11 +154,11 @@ describe("resolveSubagentSpawnContext", () => { } } - const client = createMockClient((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { const session = sessions[opts.path.id] if (session) return { data: session } return { error: "not found", data: undefined } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when - resolve from the deepest session const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}` @@ -170,7 +171,7 @@ describe("resolveSubagentSpawnContext", () => { test("detects parent cycle and throws", async () => { // given - A -> B -> A (cycle) - const client = createMockClient((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { const sessions: Record = { "session-a": { id: "session-a", parentID: "session-b" }, "session-b": { id: "session-b", parentID: "session-a" }, @@ -178,7 +179,7 @@ describe("resolveSubagentSpawnContext", () => { const session = sessions[opts.path.id] if (session) return { data: session } return { error: "not found", data: undefined } - }) as unknown as OpencodeClient["session"]["get"]) + }))) // when const result = resolveSubagentSpawnContext(client, "session-a") diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 884ec31d6..e68e3dfb7 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { TASK_CLEANUP_DELAY_MS } from "./constants" import { BackgroundManager } from "./manager" import type { BackgroundTask } from "./types" +import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" type PromptAsyncCall = { path: { id: string } @@ -11,6 +12,9 @@ type PromptAsyncCall = { noReply?: boolean parts?: unknown[] } + query?: { + directory: string + } } type FakeTimers = { @@ -159,14 +163,6 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back return notifyParentSession.call(manager, task) } -function waitForDeferredWake(): Promise { - return new Promise((resolve) => setTimeout(resolve, 180)) -} - -function waitForDeferredWakeRetry(): Promise { - return new Promise((resolve) => setTimeout(resolve, 1_180)) -} - function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType { const timer = getCompletionTimers(manager).get(taskID) expect(timer).toBeDefined() @@ -241,6 +237,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { // then expect(promptAsyncCalls).toHaveLength(2) + expect(promptAsyncCalls[0]?.body.noReply).toBe(true) expect(getCompletionTimers(manager).size).toBe(2) const allCompleteCall = promptAsyncCalls[1] expect(allCompleteCall).toBeDefined() @@ -251,13 +248,14 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { expect(allCompleteCall.body.noReply).toBe(false) const allCompletePayload = JSON.stringify(allCompleteCall.body.parts) expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER) expect(allCompletePayload).toContain(taskA.id) expect(allCompletePayload).toContain(taskB.id) expect(allCompletePayload).toContain(taskA.description) expect(allCompletePayload).toContain(taskB.description) }) - test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => { + test("#when parent session is busy #then all-complete notification keeps the direct 4.0.0 parent prompt behavior", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, @@ -272,10 +270,32 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { await notifyParentSessionForTest(manager, task) // then - expect(promptAsyncCalls).toHaveLength(0) + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body.noReply).toBe(false) + const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) + expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) - test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => { + test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => { + // given + const { manager, promptAsyncCalls } = createManager(true) + managerUnderTest = manager + const directory = Reflect.get(manager, "directory") as string + const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) + getTasks(manager).set(task.id, task) + getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) + + // when + await notifyParentSessionForTest(manager, task) + + // then + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body.noReply).toBe(false) + expect(promptAsyncCalls[0]?.query).toEqual({ directory }) + }) + + test("#when busy parent later becomes idle #then completion notification is not replayed as a second parent prompt", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, @@ -286,21 +306,22 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) await notifyParentSessionForTest(manager, task) + expect(promptAsyncCalls).toHaveLength(1) // when sessionStatuses["parent-1"] = { type: "idle" } manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) - await waitForDeferredWake() + await Promise.resolve() // then expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls[0]?.body.noReply).toBe(false) - const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) - expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") - expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") + const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) + expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") }) - test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => { + test("#when a single background task finishes during a stale busy parent status #then no deferred wake is scheduled", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, @@ -314,22 +335,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { // when await notifyParentSessionForTest(manager, task) sessionStatuses["parent-1"] = { type: "idle" } - await waitForDeferredWakeRetry() + await new Promise((resolve) => setTimeout(resolve, 1_180)) // then expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls[0]?.body.noReply).toBe(false) - const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) - expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") - expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") + const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) + expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") }) - test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => { + test("#when completion notification send is aborted #then notification is queued for the next user message", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } - const promptError = new Error("promptAsync failed") + const promptError = new Error("Request aborted while waiting for input") + promptError.name = "MessageAbortedError" const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => { throw promptError }) @@ -337,12 +359,9 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) - await notifyParentSessionForTest(manager, task) // when - sessionStatuses["parent-1"] = { type: "idle" } - manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) - await waitForDeferredWake() + await notifyParentSessionForTest(manager, task) // then expect(promptAsyncCalls).toHaveLength(1) diff --git a/src/features/claude-code-mcp-loader/loader.ts b/src/features/claude-code-mcp-loader/loader.ts index 7be6a9ac7..54d5cb3e5 100644 --- a/src/features/claude-code-mcp-loader/loader.ts +++ b/src/features/claude-code-mcp-loader/loader.ts @@ -11,6 +11,7 @@ import type { import { transformMcpServer } from "./transformer" import { log } from "../../shared/logger" import { shouldLoadMcpServer } from "./scope-filter" +import { bunFile } from "../../shared/bun-file-shim" interface McpConfigPath { path: string @@ -37,7 +38,7 @@ async function loadMcpConfigFile( } try { - const content = await Bun.file(filePath).text() + const content = await bunFile(filePath).text() return JSON.parse(content) as ClaudeCodeMcpConfig } catch (error) { log(`Failed to load MCP config from ${filePath}`, error) diff --git a/src/features/claude-code-plugin-loader/mcp-server-loader.ts b/src/features/claude-code-plugin-loader/mcp-server-loader.ts index b0f0f8b8f..3804bdb7d 100644 --- a/src/features/claude-code-plugin-loader/mcp-server-loader.ts +++ b/src/features/claude-code-plugin-loader/mcp-server-loader.ts @@ -7,6 +7,7 @@ import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types" import { log } from "../../shared/logger" import type { LoadedPlugin } from "./types" import { resolvePluginPaths } from "./plugin-path-resolver" +import { bunFile } from "../../shared/bun-file-shim" export async function loadPluginMcpServers( plugins: LoadedPlugin[], @@ -18,7 +19,7 @@ export async function loadPluginMcpServers( if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue try { - const content = await Bun.file(plugin.mcpPath).text() + const content = await bunFile(plugin.mcpPath).text() let config = JSON.parse(content) as ClaudeCodeMcpConfig config = resolvePluginPaths(config, plugin.installPath) diff --git a/src/features/context-injector/injector.test.ts b/src/features/context-injector/injector.test.ts index 09de376fe..c9e98c330 100644 --- a/src/features/context-injector/injector.test.ts +++ b/src/features/context-injector/injector.test.ts @@ -3,6 +3,7 @@ import { ContextCollector } from "./collector" import { createContextInjectorMessagesTransformHook, } from "./injector" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("createContextInjectorMessagesTransformHook", () => { let collector: ContextCollector @@ -51,7 +52,7 @@ describe("createContextInjectorMessagesTransformHook", () => { createMockMessage("user", "Second message", sessionID), ] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const output = { messages } as any + const output = unsafeTestValue({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -115,7 +116,7 @@ describe("createContextInjectorMessagesTransformHook", () => { const sessionID = "ses_transform2" const messages = [createMockMessage("user", "Hello world", sessionID)] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const output = { messages } as any + const output = unsafeTestValue({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -135,7 +136,7 @@ describe("createContextInjectorMessagesTransformHook", () => { }) const messages = [createMockMessage("assistant", "Response", sessionID)] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const output = { messages } as any + const output = unsafeTestValue({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -156,7 +157,7 @@ describe("createContextInjectorMessagesTransformHook", () => { }) const messages = [createMockMessage("user", "Message", sessionID)] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const output = { messages } as any + const output = unsafeTestValue({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) diff --git a/src/features/context-injector/injector.ts b/src/features/context-injector/injector.ts index 8a52de914..eff4faaec 100644 --- a/src/features/context-injector/injector.ts +++ b/src/features/context-injector/injector.ts @@ -79,6 +79,14 @@ type MessagesTransformHook = { ) => Promise } +function getSessionIDFromMessageInfo(info: Message): string | undefined { + return "sessionID" in info && typeof info.sessionID === "string" ? info.sessionID : undefined +} + +function hasText(part: Part): boolean { + return "text" in part && typeof part.text === "string" && part.text.length > 0 +} + export function createContextInjectorMessagesTransformHook( collector: ContextCollector ): MessagesTransformHook { @@ -106,8 +114,7 @@ export function createContextInjectorMessagesTransformHook( } const lastUserMessage = messages[lastUserMessageIndex] - // Try message.info.sessionID first, fallback to mainSessionID - const messageSessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID + const messageSessionID = getSessionIDFromMessageInfo(lastUserMessage.info) const sessionID = messageSessionID ?? getMainSessionID() log("[DEBUG] Extracted sessionID", { messageSessionID, @@ -135,7 +142,7 @@ export function createContextInjectorMessagesTransformHook( } const textPartIndex = lastUserMessage.parts.findIndex( - (p) => p.type === "text" && (p as { text?: string }).text + (p) => p.type === "text" && hasText(p) ) if (textPartIndex === -1) { @@ -150,7 +157,7 @@ export function createContextInjectorMessagesTransformHook( const syntheticPart = { id: `synthetic_hook_${sessionID}`, messageID: lastUserMessage.info.id, - sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "", + sessionID: messageSessionID ?? "", type: "text" as const, text: pending.merged, synthetic: true, // hidden in UI diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index a50aefa95..0a5bb7387 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -11,6 +11,7 @@ import { injectHookMessage, } from "./injector" import { getCompactionPartStorageDir } from "../../shared/compaction-marker" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" //#region Mocks @@ -73,7 +74,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toEqual({ agent: "sisyphus", @@ -87,7 +88,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toEqual({ agent: "sisyphus", @@ -102,7 +103,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { id: "msg_new", info: { agent: "new-agent", model: { providerID: "new", modelID: "model" }, time: { created: 20 } } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result?.agent).toBe("new-agent") }) @@ -112,7 +113,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: { agent: "partial-agent" } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result?.agent).toBe("partial-agent") }) @@ -123,7 +124,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: {} }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -131,7 +132,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { it("returns null when messages array is empty", async () => { const mockClient = createMockClient([]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -145,7 +146,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, } - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -161,7 +162,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result?.tools).toEqual({ edit: true, write: false }) }) @@ -172,7 +173,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { id: "msg_older", info: { agent: "newest-by-time", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 100 } } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result?.agent).toBe("newest-by-time") }) @@ -190,7 +191,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result?.agent).toBe("sisyphus") }) @@ -252,7 +253,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: { agent: "second-agent" } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBe("first-agent") }) @@ -263,7 +264,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { id: "msg_early", info: { agent: "earliest-agent", time: { created: 10 } } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBe("earliest-agent") }) @@ -274,7 +275,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBe("sisyphus") }) @@ -285,7 +286,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: { agent: "first-real-agent" } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBe("first-real-agent") }) @@ -296,7 +297,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: {} }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -310,7 +311,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { }, } - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 48dcb1729..0d4b3410b 100644 --- a/src/features/mcp-oauth/callback-server.ts +++ b/src/features/mcp-oauth/callback-server.ts @@ -1,3 +1,5 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" + import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils" const DEFAULT_PORT = 19877 @@ -51,56 +53,73 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro const timeoutId = setTimeout(() => { rejectCallback?.(new Error("OAuth callback timed out after 5 minutes")) - server.stop(true) + server.close() }, TIMEOUT_MS) - const server = Bun.serve({ - port: requestedPort, - hostname: "127.0.0.1", - fetch(request: Request): Response { - const url = new URL(request.url) + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1") - if (url.pathname !== "/oauth/callback") { - return new Response("Not Found", { status: 404 }) - } + if (url.pathname !== "/oauth/callback") { + response.statusCode = 404 + response.end("Not Found") + return + } - const oauthError = url.searchParams.get("error") - if (oauthError) { - const description = url.searchParams.get("error_description") ?? oauthError - clearTimeout(timeoutId) - rejectCallback?.(new Error(`OAuth authorization failed: ${description}`)) - setTimeout(() => server.stop(true), 100) - return new Response(`Authorization failed: ${description}`, { status: 400 }) - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - - if (!code || !state) { - clearTimeout(timeoutId) - rejectCallback?.(new Error("OAuth callback missing code or state parameter")) - setTimeout(() => server.stop(true), 100) - return new Response("Missing code or state parameter", { status: 400 }) - } - - resolveCallback?.({ code, state }) + const oauthError = url.searchParams.get("error") + if (oauthError) { + const description = url.searchParams.get("error_description") ?? oauthError clearTimeout(timeoutId) + rejectCallback?.(new Error(`OAuth authorization failed: ${description}`)) + response.statusCode = 400 + response.end(`Authorization failed: ${description}`) + setTimeout(() => server.close(), 100) + return + } - setTimeout(() => server.stop(true), 100) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") - return new Response(SUCCESS_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }) - }, + if (!code || !state) { + clearTimeout(timeoutId) + rejectCallback?.(new Error("OAuth callback missing code or state parameter")) + response.statusCode = 400 + response.end("Missing code or state parameter") + setTimeout(() => server.close(), 100) + return + } + + resolveCallback?.({ code, state }) + clearTimeout(timeoutId) + + response.statusCode = 200 + response.setHeader("content-type", "text/html; charset=utf-8") + response.end(SUCCESS_HTML) + setTimeout(() => server.close(), 100) }) - const activePort = server.port ?? requestedPort + + await new Promise((resolve, reject) => { + const handleError = (error: Error): void => { + clearTimeout(timeoutId) + reject(error) + } + + server.once("error", handleError) + server.once("listening", () => { + server.off("error", handleError) + resolve() + }) + server.listen(requestedPort, "127.0.0.1") + }) + + const address = server.address() + const activePort = typeof address === "object" && address !== null ? address.port : requestedPort return { port: activePort, waitForCallback: () => callbackPromise, close: () => { clearTimeout(timeoutId) - server.stop(true) + server.close() }, } } diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index f3ef6f51e..accd4db20 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -6,6 +6,7 @@ import type { OAuthTokenData } from "../mcp-oauth/storage" import { setHttpClientDependenciesForTesting } from "./http-client" import { setStdioClientDependenciesForTesting } from "./stdio-client" import { SkillMcpManager } from "./manager" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) const mockHttpClose = mock(() => Promise.resolve()) @@ -634,7 +635,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -668,7 +669,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when / #then @@ -700,7 +701,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when / #then @@ -929,7 +930,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -962,7 +963,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when / #then diff --git a/src/features/task-toast-manager/manager.test.ts b/src/features/task-toast-manager/manager.test.ts index 92ab524a0..77dcd7c83 100644 --- a/src/features/task-toast-manager/manager.test.ts +++ b/src/features/task-toast-manager/manager.test.ts @@ -1,6 +1,7 @@ declare const require: (name: string) => any const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test") import type { ConcurrencyManager } from "../background-agent/concurrency" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type TaskToastManagerClass = typeof import("./manager").TaskToastManager @@ -20,15 +21,15 @@ describe("TaskToastManager", () => { showToast: mock(() => Promise.resolve()), }, } - mockConcurrencyManager = { + mockConcurrencyManager = unsafeTestValue({ getConcurrencyLimit: mock(() => 5), - } as unknown as ConcurrencyManager + }) const mod = await import("./manager") TaskToastManager = mod.TaskToastManager // eslint-disable-next-line @typescript-eslint/no-explicit-any - toastManager = new TaskToastManager(mockClient as any, mockConcurrencyManager) + toastManager = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyManager) }) afterEach(() => { @@ -108,14 +109,14 @@ describe("TaskToastManager", () => { test("should display concurrency limit info when available", () => { // given - a concurrency manager with known limit - const mockConcurrencyWithCounts = { + const mockConcurrencyWithCounts = unsafeTestValue({ getConcurrencyLimit: mock(() => 5), getRunningCount: mock(() => 2), getQueuedCount: mock(() => 1), - } as unknown as ConcurrencyManager + }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const managerWithConcurrency = new TaskToastManager(mockClient as any, mockConcurrencyWithCounts) + const managerWithConcurrency = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyWithCounts) // when - a task is added managerWithConcurrency.addTask({ @@ -357,11 +358,11 @@ describe("TaskToastManager", () => { test("should show model name in queued tasks too", () => { // given - a concurrency manager that limits to 1 - const limitedConcurrency = { + const limitedConcurrency = unsafeTestValue({ getConcurrencyLimit: mock(() => 1), - } as unknown as ConcurrencyManager + }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency) + const limitedManager = new TaskToastManager(unsafeTestValue(mockClient), limitedConcurrency) limitedManager.addTask({ id: "task_running", diff --git a/src/features/team-mode/deps.ts b/src/features/team-mode/deps.ts index 25db5f966..ecb06c238 100644 --- a/src/features/team-mode/deps.ts +++ b/src/features/team-mode/deps.ts @@ -1,4 +1,5 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" +import { spawn } from "../../shared/bun-spawn-shim" export interface TeamModeDependencyReport { tmuxAvailable: boolean @@ -20,7 +21,7 @@ export async function checkTeamModeDependencies( async function probeBinary(cmd: string, args: string[]): Promise { try { - const proc = Bun.spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" }) + const proc = spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" }) const code = await proc.exited return code === 0 } catch { diff --git a/src/features/team-mode/team-runtime/cleanup-team-run-resources.test.ts b/src/features/team-mode/team-runtime/cleanup-team-run-resources.test.ts index 22ee7d53d..702380600 100644 --- a/src/features/team-mode/team-runtime/cleanup-team-run-resources.test.ts +++ b/src/features/team-mode/team-runtime/cleanup-team-run-resources.test.ts @@ -16,6 +16,7 @@ import { import { saveRuntimeState } from "../team-state-store/store" import type { RuntimeState } from "../types" import { cleanupTeamRunResources } from "./cleanup-team-run-resources" +import { unsafeTestValue } from "../../../../test-support/unsafe-test-value" const temporaryDirectories: string[] = [] @@ -41,9 +42,9 @@ function createRuntimeState(teamRunId: string): RuntimeState { } function createStubBgMgr(): BackgroundManager { - return { + return unsafeTestValue({ cancelTask: async () => undefined, - } as unknown as BackgroundManager + }) } describe("cleanupTeamRunResources", () => { diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 212985cbd..dda4c0468 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig, WindowState } from "./types" import * as sharedModule from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -1098,9 +1099,9 @@ export class TmuxSessionManager { if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (!this.sourcePaneId) { diff --git a/src/features/tmux-subagent/polling-manager-event-session-id.test.ts b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts new file mode 100644 index 000000000..486862f8f --- /dev/null +++ b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" + +import { TmuxPollingManager } from "./polling-manager" +import type { TrackedSession } from "./types" + +describe("TmuxPollingManager event session ids", () => { + test("#given legacy message.part.updated properties #when handling activity #then part session id increments activity version", () => { + const sessions = new Map() + sessions.set("ses-part-only", { + sessionId: "ses-part-only", + paneId: "%1", + description: "test", + createdAt: new Date(), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + }) + + const client = { + session: { + status: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + const manager = new TmuxPollingManager(client as never, sessions, async () => {}) + + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }, + }) + + expect(sessions.get("ses-part-only")?.activityVersion).toBe(1) + }) +}) diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 38b32797f..7c0d3cd1d 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test" import { TmuxPollingManager } from "./polling-manager" import type { TrackedSession } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("TmuxPollingManager overlap", () => { test("skips overlapping pollSessions executions", async () => { @@ -39,15 +40,15 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async () => {}, ) //#when - const firstPoll = (manager as unknown as { pollSessions: () => Promise }).pollSessions() + const firstPoll = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions() await Promise.resolve() - const secondPoll = (manager as unknown as { pollSessions: () => Promise }).pollSessions() + const secondPoll = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions() releaseStatus?.() await Promise.all([firstPoll, secondPoll]) @@ -85,7 +86,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -98,7 +99,7 @@ describe("TmuxPollingManager overlap", () => { }) //#when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) await pollSessions.call(manager) await pollSessions.call(manager) @@ -132,7 +133,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -140,7 +141,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -171,7 +172,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -179,7 +180,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -222,13 +223,13 @@ describe("TmuxPollingManager overlap", () => { } manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) }, ) - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions // when await pollSessions.call(manager) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 74e017c5e..c34e126aa 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -7,6 +7,7 @@ import { import type { TrackedSession } from "./types" import { log } from "../../shared" import { normalizeSDKResponse } from "../../shared" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" const MIN_STABILITY_TIME_MS = 10 * 1000 const STABLE_POLLS_REQUIRED = 3 @@ -170,10 +171,7 @@ export class TmuxPollingManager { if (!properties) return undefined if (event.type === "message.updated") { - const info = properties.info - if (!info || typeof info !== "object") return undefined - const sessionId = (info as { sessionID?: unknown }).sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } if ( @@ -182,8 +180,7 @@ export class TmuxPollingManager { || event.type === "message.part.removed" || event.type === "message.removed" ) { - const sessionId = properties.sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } return undefined diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts index fa6fcc24e..a80cdd546 100644 --- a/src/features/tmux-subagent/session-created-handler.ts +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { CapacityConfig, TrackedSession } from "./types" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, type SessionMapping } from "./decision-engine" import { executeActions } from "./action-executor" @@ -44,9 +45,9 @@ export async function handleSessionCreated( if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index ef2a7b3d9..d5ea75ecd 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; import type { AgentUsageState } from "./types"; import { getSessionAgent } from "../../features/claude-code-session-state"; import { getAgentConfigKey } from "../../shared/agent-display-names"; +import { resolveSessionEventID } from "../../shared/event-session-id"; interface ToolExecuteInput { tool: string; @@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - resetState(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + resetState(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { resetState(sessionID); } diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index 28dd23415..9c529007d 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -5,6 +5,7 @@ import { executeCompact } from "./executor" import type { AutoCompactState } from "./types" import * as recoveryStrategy from "./recovery-strategy" import * as messagesReader from "../session-recovery/storage/messages-reader" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type TimerCallback = (...args: any[]) => void @@ -37,7 +38,7 @@ function createFakeTimeouts(): FakeTimeouts { callback, args, }) - return id as unknown as ReturnType + return unsafeTestValue>(id) }) as typeof setTimeout globalThis.clearTimeout = ((id?: number) => { @@ -243,7 +244,7 @@ describe("executeCompact lock management", () => { await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig) // then: Toast should be shown - const toastCalls = (mockClient.tui.showToast as any).mock.calls + const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls const blockedToast = toastCalls.find( (call: any) => call[0]?.body?.title === "Compact In Progress", ) @@ -276,7 +277,7 @@ describe("executeCompact lock management", () => { await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig) // then: Should show failure toast - const toastCalls = (mockClient.tui.showToast as any).mock.calls + const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls const failureToast = toastCalls.find( (call: any) => call[0]?.body?.title === "Auto Compact Failed", ) diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts index 68f23b3b0..d6032a761 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import type { ExperimentalConfig } from "../../config" import * as originalDeduplicationRecovery from "./deduplication-recovery" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const attemptDeduplicationRecoveryMock = mock(async () => {}) @@ -20,7 +21,7 @@ function createImmediateTimeouts(): () => void { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { callback(...args) - return 0 as unknown as ReturnType + return unsafeTestValue>(0) }) as typeof setTimeout globalThis.clearTimeout = ((_: ReturnType) => {}) as typeof clearTimeout diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts index 0a80d63bc..862c3b8a3 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor" import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { clearSessionState } from "./state" import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" export interface AnthropicContextWindowLimitRecoveryOptions { @@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID) - clearSessionState(autoCompactState, sessionInfo.id) + clearSessionState(autoCompactState, sessionID) } return } if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error }) if (!sessionID) return @@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (sessionID && info?.role === "assistant" && info.error) { dependencies.log("[auto-compact] message.updated with error", { sessionID, error: info.error }) @@ -137,7 +138,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (!autoCompactState.pendingCompact.has(sessionID)) return diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts index 332aeda20..7d955909b 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { runSummarizeRetryStrategy } from "./summarize-retry-strategy" import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types" import type { OhMyOpenCodeConfig } from "../../config" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type TimeoutCall = { handle: ReturnType @@ -95,7 +96,7 @@ describe("runSummarizeRetryStrategy", () => { //#given const timeoutCalls: TimeoutCall[] = [] globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => { - const handle = timeoutCalls.length + 1 as unknown as ReturnType + const handle = unsafeTestValue>(timeoutCalls.length + 1) timeoutCalls.push({ handle, delay: delay ?? 0 }) return handle }) as typeof setTimeout @@ -132,7 +133,7 @@ describe("runSummarizeRetryStrategy", () => { let scheduledCallback: (() => void) | undefined globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => { scheduledCallback = () => callback() - return 1 as unknown as ReturnType + return unsafeTestValue>(1) }) as typeof setTimeout autoCompactState.pendingCompact.add(sessionID) @@ -176,7 +177,7 @@ describe("runSummarizeRetryStrategy", () => { autoCompactState.emptyContentAttemptBySession.set(sessionID, 3) autoCompactState.retryTimerBySession.set( sessionID, - 1 as unknown as ReturnType, + unsafeTestValue>(1), ) //#when diff --git a/src/hooks/atlas/background-task-retry.test.ts b/src/hooks/atlas/background-task-retry.test.ts index e8a9cded6..8cab40ba2 100644 --- a/src/hooks/atlas/background-task-retry.test.ts +++ b/src/hooks/atlas/background-task-retry.test.ts @@ -7,6 +7,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createAtlasHook } from "./atlas-hook" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" // Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests) mock.module("../../shared/opencode-storage-detection", () => ({ @@ -79,7 +80,7 @@ describe("atlas background task retry", () => { callback: () => (callback as LongTimerCallback)(...args), cleared: false, }) - return id as unknown as ReturnType + return unsafeTestValue>(id) } return originalSetTimeout(callback, delay, ...args) @@ -120,7 +121,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -128,13 +129,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -161,7 +162,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -169,13 +170,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -204,7 +205,7 @@ describe("atlas background task retry", () => { let remainingRunningRetries = 2 const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -212,9 +213,11 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> + }>({ getTasksByParentSession: () => { if (remainingRunningRetries > 0) { remainingRunningRetries -= 1 @@ -223,9 +226,7 @@ describe("atlas background task retry", () => { return [] }, - } as unknown as NonNullable[1]>["backgroundManager"] & { - getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }), }) // when @@ -258,7 +259,7 @@ describe("atlas background task retry", () => { const promptAsyncMock = mock(async () => ({})) let backgroundCheckCount = 0 - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -266,9 +267,11 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> + }>({ getTasksByParentSession: () => { backgroundCheckCount += 1 if (backgroundCheckCount === 1) { @@ -281,9 +284,7 @@ describe("atlas background task retry", () => { return [] }, - } as unknown as NonNullable[1]>["backgroundManager"] & { - getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }), }) // when @@ -313,7 +314,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -321,13 +322,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -366,7 +367,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true let descendantAgent = "atlas" const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -384,18 +385,18 @@ describe("atlas background task retry", () => { }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> + }>({ getTasksByParentSession: (currentSessionID: string) => { if (currentSessionID !== descendantSessionID) { return [] } return backgroundRunning ? [{ status: "running" }] : [] }, - } as unknown as NonNullable[1]>["backgroundManager"] & { - getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }), }) // when @@ -424,7 +425,7 @@ describe("atlas background task retry", () => { const deferredPrompt = createDeferred<{}>() const promptAsyncMock = mock(() => deferredPrompt.promise) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -432,7 +433,7 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput) + })) // when const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) @@ -462,7 +463,7 @@ describe("atlas background task retry", () => { promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -470,13 +471,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => [], + }), }) // when @@ -515,7 +516,7 @@ describe("atlas background task retry", () => { }) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -523,13 +524,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when diff --git a/src/hooks/atlas/boulder-continuation-injector.test.ts b/src/hooks/atlas/boulder-continuation-injector.test.ts index d26b4b850..902bfe78f 100644 --- a/src/hooks/atlas/boulder-continuation-injector.test.ts +++ b/src/hooks/atlas/boulder-continuation-injector.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state" import { injectBoulderContinuation } from "./boulder-continuation-injector" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("injectBoulderContinuation", () => { beforeEach(() => { @@ -20,7 +21,7 @@ describe("injectBoulderContinuation", () => { const promptAsyncMock = mock(async (_request: unknown) => undefined) const messagesMock = mock(async () => ({ data: [] })) - const ctx = { + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -28,7 +29,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 } - const ctx = { + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -68,7 +69,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -78,9 +79,9 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: { + backgroundManager: unsafeTestValue[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "running" }], - } as unknown as Parameters[0]["backgroundManager"], + }), sessionState, }) @@ -98,7 +99,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 } - const ctx = { + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -106,7 +107,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -116,9 +117,9 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: { + backgroundManager: unsafeTestValue[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "pending" }], - } as unknown as Parameters[0]["backgroundManager"], + }), sessionState, }) @@ -134,7 +135,7 @@ describe("injectBoulderContinuation", () => { const promptAsyncMock = mock(async (_request: unknown) => undefined) const messagesMock = mock(async () => ({ data: [] })) - const ctx = { + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -142,7 +143,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -189,7 +190,7 @@ describe("injectBoulderContinuation", () => { }], })) - const ctx = { + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -197,7 +198,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index e9358b7ad..2ad001df4 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { HOOK_NAME } from "./hook-name" import { isAbortError } from "./is-abort-error" import { handleAtlasSessionIdle } from "./idle-event" @@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: { const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const state = getState(sessionID) @@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: { } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) return @@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: { if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID) return @@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: { if (event.type === "message.part.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (sessionID && role === "assistant") { @@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: { } if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (sessionID) { const state = sessions.get(sessionID) if (state) { @@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: { } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - const deletedState = sessions.get(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + const deletedState = sessions.get(sessionID) if (deletedState?.pendingRetryTimer) { clearTimeout(deletedState.pendingRetryTimer) } - sessions.delete(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + sessions.delete(sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } return } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { const compactedState = sessions.get(sessionID) if (compactedState?.pendingRetryTimer) { diff --git a/src/hooks/atlas/idle-event-complete-boulder.test.ts b/src/hooks/atlas/idle-event-complete-boulder.test.ts index a03b27fe7..6d5d306ea 100644 --- a/src/hooks/atlas/idle-event-complete-boulder.test.ts +++ b/src/hooks/atlas/idle-event-complete-boulder.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const { createAtlasHook } = await import("./index") @@ -49,7 +50,7 @@ describe("atlas hook idle-event complete boulder", () => { }, }) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { @@ -59,7 +60,7 @@ describe("atlas hook idle-event complete boulder", () => { promptAsync: async () => ({ data: {} }), }, }, - } as unknown as Parameters[0]) + })) // when await hook.handler({ diff --git a/src/hooks/atlas/idle-event-lineage.test.ts b/src/hooks/atlas/idle-event-lineage.test.ts index 5beea6397..ff5e50f7b 100644 --- a/src/hooks/atlas/idle-event-lineage.test.ts +++ b/src/hooks/atlas/idle-event-lineage.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state" import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const { createAtlasHook } = await import("./index") @@ -32,7 +33,7 @@ describe("atlas hook idle-event session lineage", () => { } function createHook(parentSessionIDs?: Record) { - return createAtlasHook({ + return createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { @@ -52,7 +53,7 @@ describe("atlas hook idle-event session lineage", () => { }, }, }, - } as unknown as Parameters[0]) + })) } beforeEach(() => { diff --git a/src/hooks/atlas/idle-event-persisted-lineage.test.ts b/src/hooks/atlas/idle-event-persisted-lineage.test.ts index a079bf5a0..af2c65575 100644 --- a/src/hooks/atlas/idle-event-persisted-lineage.test.ts +++ b/src/hooks/atlas/idle-event-persisted-lineage.test.ts @@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" import type { BoulderState } from "../../features/boulder-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`) const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") @@ -58,7 +59,7 @@ describe("atlas hook idle-event persisted lineage", () => { parentSessionIDs?: Record, messagesBySession?: Record>, ) { - return createAtlasHook({ + return createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { @@ -79,7 +80,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }, }, - } as unknown as Parameters[0]) + })) } beforeEach(() => { @@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }) - const hook = createAtlasHook({ + const hook = createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { @@ -193,7 +194,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }, }, - } as unknown as Parameters[0]) + })) // when await hook.handler({ diff --git a/src/hooks/atlas/idle-event.test.ts b/src/hooks/atlas/idle-event.test.ts index d97783c4e..61f9204bf 100644 --- a/src/hooks/atlas/idle-event.test.ts +++ b/src/hooks/atlas/idle-event.test.ts @@ -8,6 +8,7 @@ import { createBoulderState, readBoulderState, writeBoulderState } from "../../f import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" import { handleAtlasSessionIdle } from "./idle-event" import type { SessionState } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("handleAtlasSessionIdle completion nudge", () => { const SESSION_ID = "session-main-1" @@ -76,14 +77,14 @@ describe("handleAtlasSessionIdle completion nudge", () => { return { data: {} } }) - const ctx = { + const ctx = unsafeTestValue({ directory: testDirectory, client: { session: { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) const sessionStateById = new Map() const getState = (sessionId: string): SessionState => { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 9e5692e44..f3f9f5602 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999 expect(callArgs.body.parts[0].text).toContain("2 remaining") }) + test("should inject continuation when idle event carries session id in info", async () => { + // given - boulder state with incomplete plan and nested session event shape + const planPath = join(TEST_DIR, "test-plan-info-idle.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan-info-idle", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { info: { id: MAIN_SESSION_ID } }, + }, + }) + + // then - should call prompt with continuation + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.path.id).toBe(MAIN_SESSION_ID) + expect(callArgs.body.parts[0].text).toContain("incomplete tasks") + expect(callArgs.body.parts[0].text).toContain("2 remaining") + }) + test("should settle idle before injecting boulder continuation", async () => { // given const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/atlas/recent-model-resolver.test.ts b/src/hooks/atlas/recent-model-resolver.test.ts index 81db7dbe4..e326e48fa 100644 --- a/src/hooks/atlas/recent-model-resolver.test.ts +++ b/src/hooks/atlas/recent-model-resolver.test.ts @@ -1,11 +1,12 @@ import { describe, expect, mock, test } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("resolveRecentPromptContextForSession", () => { test("uses message time.created rather than SDK array order for recent prompt context", async () => { // given - const ctx = { + const ctx = unsafeTestValue({ client: { session: { messages: mock(async () => ({ @@ -32,7 +33,7 @@ describe("resolveRecentPromptContextForSession", () => { })), }, }, - } as unknown as PluginInput + }) // when const result = await resolveRecentPromptContextForSession(ctx, "ses_123") diff --git a/src/hooks/atlas/tool-execute-after-background-launch.test.ts b/src/hooks/atlas/tool-execute-after-background-launch.test.ts index 1a7d55894..4bca3e302 100644 --- a/src/hooks/atlas/tool-execute-after-background-launch.test.ts +++ b/src/hooks/atlas/tool-execute-after-background-launch.test.ts @@ -8,6 +8,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { Project } from "@opencode-ai/sdk" import { readBoulderState, writeBoulderState } from "../../features/boulder-state" import { createToolExecuteBeforeHandler } from "./tool-execute-before" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const isCallerOrchestratorMock = mock(async () => true) const collectGitDiffStatsMock = mock(() => ({ @@ -80,11 +81,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { function createHandler(parentSessionIDs?: Record) { const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), }, - } as unknown as PluginInput["client"] + }) if (parentSessionIDs) { spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( @@ -141,11 +142,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_child123" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined), @@ -215,11 +216,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_child_lookup_failure" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => { if (input?.path?.id === childSessionID) { @@ -288,11 +289,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_outside_lineage" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined), @@ -358,11 +359,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_unrelated_child" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined), @@ -431,11 +432,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const planPathA = join(testDirectory, "background-launch-work-a.md") const planPathB = join(testDirectory, "background-launch-work-b.md") const project = createProject() - const client = { + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined), diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts index 73083f20d..1803394d1 100644 --- a/src/hooks/auto-slash-command/hook.ts +++ b/src/hooks/auto-slash-command/hook.ts @@ -5,6 +5,7 @@ import { } from "./detector" import { executeSlashCommand, type ExecutorOptions } from "./executor" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { AUTO_SLASH_COMMAND_TAG_CLOSE, AUTO_SLASH_COMMAND_TAG_OPEN, @@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record { } function getDeletedSessionID(properties: unknown): string | null { - if (!isRecord(properties)) { - return null - } - - const info = properties.info - if (!isRecord(info)) { - return null - } - - return typeof info.id === "string" ? info.id : null + return resolveSessionEventID(properties) ?? null } function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null { @@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | "commandId", ] - const recordInput = input as unknown + const recordInput: unknown = input if (!isRecord(recordInput)) { return null } diff --git a/src/hooks/auto-update-checker/checker/cached-version.test.ts b/src/hooks/auto-update-checker/checker/cached-version.test.ts index 352de6d19..9d4c55806 100644 --- a/src/hooks/auto-update-checker/checker/cached-version.test.ts +++ b/src/hooks/auto-update-checker/checker/cached-version.test.ts @@ -15,7 +15,7 @@ mock.module("../constants", () => ({ 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] + const value = (unsafeTestValue>(current))[prop] if (typeof value === "function") { return (value as (...args: unknown[]) => unknown).bind(current) } @@ -29,6 +29,7 @@ mock.module("./package-json-locator", () => ({ })) import { getCachedVersion } from "./cached-version" +import { unsafeTestValue } from "../../../../test-support/unsafe-test-value" describe("getCachedVersion (GH-3257)", () => { let cacheRoot: string diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index a89d182b0..f940e6288 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" import { getAgentConfigKey } from "../../shared/agent-display-names" +import { resolveSessionEventID } from "../../shared/event-session-id" import { buildReminderMessage } from "./formatter" /** @@ -120,15 +121,14 @@ export function createCategorySkillReminderHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionStates.delete(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + sessionStates.delete(sessionID) } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { sessionStates.delete(sessionID) } diff --git a/src/hooks/category-skill-reminder/index.test.ts b/src/hooks/category-skill-reminder/index.test.ts index 08d6118b8..2bbcf9052 100644 --- a/src/hooks/category-skill-reminder/index.test.ts +++ b/src/hooks/category-skill-reminder/index.test.ts @@ -3,6 +3,7 @@ import { createCategorySkillReminderHook } from "./index" import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import * as sharedModule from "../../shared" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("category-skill-reminder hook", () => { let logCalls: Array<{ msg: string; data?: unknown }> @@ -21,13 +22,13 @@ describe("category-skill-reminder hook", () => { }) function createMockPluginInput() { - return { + return unsafeTestValue({ client: { tui: { showToast: async () => {}, }, }, - } as any + }) } function createHook(availableSkills: AvailableSkill[] = []) { diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index a01abb5eb..ea494ffdb 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -3,6 +3,7 @@ import { join } from "path" import type { ClaudeHookEvent } from "./types" import { log } from "../../shared/logger" import { getOpenCodeConfigDir } from "../../shared" +import { bunFile } from "../../shared/bun-file-shim" const CONFIG_CACHE_TTL_MS = 30_000 @@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) @@ -31,7 +32,7 @@ describe("executeHttpHook TLS security", () => { let logCalls: Array<{ message: string; data?: unknown }> beforeEach(() => { - globalThis.fetch = mockFetch as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(mockFetch) mockFetch.mockReset() mockFetch.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts index 682611875..ad2e40fca 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" import type { HookHttp } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const mockFetch = mock(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) @@ -9,7 +10,7 @@ const originalFetch = globalThis.fetch describe("executeHttpHook", () => { beforeEach(() => { - globalThis.fetch = mockFetch as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(mockFetch) mockFetch.mockReset() mockFetch.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) @@ -33,7 +34,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) expect(mockFetch).toHaveBeenCalledTimes(1) - const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [url, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") expect(options.method).toBe("POST") expect(options.body).toBe(stdinData) @@ -44,7 +45,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Content-Type"]).toBe("application/json") }) @@ -72,7 +73,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -88,7 +89,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -104,7 +105,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer ") }) @@ -121,7 +122,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) expect(options.signal).toBeDefined() }) }) diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index ca4556dda..534b39e34 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript" import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache" import type { PluginConfig } from "../types" import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" +import { resolveSessionEventID } from "../../../shared/event-session-id" import { clearAllSessionHookState, clearSessionHookState, @@ -26,7 +27,7 @@ export function createSessionEventHandler( if (event.type === "session.error") { const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { sessionErrorState.set(sessionID, { hasError: true, @@ -38,13 +39,13 @@ export function createSessionEventHandler( if (event.type === "session.deleted") { const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - parentSessionIdCache.delete(sessionInfo.id) - clearTranscriptCache(sessionInfo.id) - clearToolInputCache(sessionInfo.id) - contextCollector?.clear(sessionInfo.id) - clearSessionHookState(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + parentSessionIdCache.delete(sessionID) + clearTranscriptCache(sessionID) + clearToolInputCache(sessionID) + contextCollector?.clear(sessionID) + clearSessionHookState(sessionID) } return } @@ -54,7 +55,7 @@ export function createSessionEventHandler( } const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const claudeConfig = await loadClaudeHooksConfig() diff --git a/src/hooks/claude-code-hooks/tool-input-cache.test.ts b/src/hooks/claude-code-hooks/tool-input-cache.test.ts index 409c56897..8141e0dd6 100644 --- a/src/hooks/claude-code-hooks/tool-input-cache.test.ts +++ b/src/hooks/claude-code-hooks/tool-input-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("tool-input-cache", () => { const originalSetInterval = globalThis.setInterval @@ -33,11 +34,11 @@ describe("tool-input-cache", () => { test("#given cleanup timer started #when stop cleanup runs #then interval is cleared and cache is emptied", async () => { //#given - const intervalHandle = { unref: mock(() => {}) } as unknown as ReturnType + const intervalHandle = unsafeTestValue>({ unref: mock(() => {}) }) const setIntervalMock = mock(() => intervalHandle) const clearIntervalMock = mock(() => {}) - globalThis.setInterval = setIntervalMock as unknown as typeof setInterval - globalThis.clearInterval = clearIntervalMock as unknown as typeof clearInterval + globalThis.setInterval = unsafeTestValue(setIntervalMock) + globalThis.clearInterval = unsafeTestValue(clearIntervalMock) const modulePath = new URL("./tool-input-cache.ts", import.meta.url).pathname const cacheModule = await import(`${modulePath}?stop-clear`) diff --git a/src/hooks/comment-checker/cli.test.ts b/src/hooks/comment-checker/cli.test.ts index c10a34a4a..a540aa003 100644 --- a/src/hooks/comment-checker/cli.test.ts +++ b/src/hooks/comment-checker/cli.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os" import { processWithCli } from "./cli-runner" import type { PendingCall } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createMockInput() { return { @@ -74,7 +75,7 @@ done const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return unsafeTestValue>(0) }) as typeof setTimeout try { @@ -102,7 +103,7 @@ done const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return unsafeTestValue>(0) }) as typeof setTimeout try { diff --git a/src/hooks/comment-checker/pending-calls.test.ts b/src/hooks/comment-checker/pending-calls.test.ts index 31f01d2fe..8c5c655d4 100644 --- a/src/hooks/comment-checker/pending-calls.test.ts +++ b/src/hooks/comment-checker/pending-calls.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("pending-calls cleanup interval", () => { test("starts cleanup once and unrefs timer", async () => { @@ -7,18 +8,18 @@ describe("pending-calls cleanup interval", () => { const setIntervalCalls: number[] = [] let unrefCalled = 0 - globalThis.setInterval = (( + globalThis.setInterval = unsafeTestValue((( _handler: TimerHandler, timeout?: number, - ..._args: any[] + ..._args: unknown[] ) => { setIntervalCalls.push(timeout as number) - return { + return unsafeTestValue>({ unref: () => { unrefCalled += 1 }, - } as unknown as ReturnType - }) as unknown as typeof setInterval + }) + })) try { const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname @@ -43,20 +44,20 @@ describe("pending-calls cleanup interval", () => { let intervalHandle: ReturnType | undefined let clearCalls = 0 - globalThis.setInterval = (( + globalThis.setInterval = unsafeTestValue((( _handler: TimerHandler, _timeout?: number, - ..._args: any[] + ..._args: unknown[] ) => { - intervalHandle = { unref: () => {} } as unknown as ReturnType + intervalHandle = unsafeTestValue>({ unref: () => {} }) return intervalHandle - }) as unknown as typeof setInterval + })) - globalThis.clearInterval = ((handle?: ReturnType) => { + globalThis.clearInterval = unsafeTestValue(((handle?: ReturnType) => { if (handle === intervalHandle) { clearCalls += 1 } - }) as unknown as typeof clearInterval + })) try { const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts index 29e253518..9251d92c0 100644 --- a/src/hooks/compaction-context-injector/hook.ts +++ b/src/hooks/compaction-context-injector/hook.ts @@ -3,6 +3,7 @@ import { clearCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" import { log } from "../../shared/logger" import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt" import { resolveSessionPromptConfig } from "./session-prompt-config-resolver" @@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: { sessionID?: string } | undefined - if (!info?.sessionID || info.role !== "assistant" || !info.id) { + const sessionID = resolveMessageEventSessionID(props) + if (!sessionID || info?.role !== "assistant" || !info.id) { return } - const tailState = getTailState(info.sessionID) + const tailState = getTailState(sessionID) if (tailState.currentMessageID && tailState.currentMessageID !== info.id) { finalizeTrackedAssistantMessage(tailState) - await maybeWarnAboutNoTextTail(info.sessionID) + await maybeWarnAboutNoTextTail(sessionID) } if (tailState.currentMessageID !== info.id) { @@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: { } if (event.type === "message.part.delta") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const messageID = props?.messageID as string | undefined const field = props?.field as string | undefined const delta = props?.delta as string | undefined diff --git a/src/hooks/compaction-context-injector/session-id.ts b/src/hooks/compaction-context-injector/session-id.ts index 29de9c5c4..e9ff8a90d 100644 --- a/src/hooks/compaction-context-injector/session-id.ts +++ b/src/hooks/compaction-context-injector/session-id.ts @@ -1,8 +1,9 @@ +import { resolveSessionEventID } from "../../shared/event-session-id" + export function isCompactionAgent(agent: string | undefined): boolean { return agent?.trim().toLowerCase() === "compaction" } export function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } diff --git a/src/hooks/compaction-todo-preserver/hook.ts b/src/hooks/compaction-todo-preserver/hook.ts index 2bfe20cac..63e744c9c 100644 --- a/src/hooks/compaction-todo-preserver/hook.ts +++ b/src/hooks/compaction-todo-preserver/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" interface TodoSnapshot { @@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise { } function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } export interface CompactionTodoPreserver { diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index acdeee1c4..30e241c3f 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -4,6 +4,7 @@ import { type ContextLimitModelCacheState, } from "../shared/context-limit-resolver" import { isCompactionAgent } from "../shared/compaction-marker" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" const CONTEXT_WARNING_THRESHOLD = 0.70 @@ -86,10 +87,10 @@ export function createContextWindowMonitorHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - remindedSessions.delete(sessionInfo.id) - tokenCache.delete(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + remindedSessions.delete(sessionID) + tokenCache.delete(sessionID) } } @@ -106,9 +107,10 @@ export function createContextWindowMonitorHook( if (!info || info.role !== "assistant" || !info.finish) return if (isCompactionAgent(info.agent)) return - if (!info.sessionID || !info.providerID || !info.tokens) return + const sessionID = resolveMessageEventSessionID(props) + if (!sessionID || !info.providerID || !info.tokens) return - tokenCache.set(info.sessionID, { + tokenCache.set(sessionID, { providerID: info.providerID, modelID: info.modelID ?? "", tokens: info.tokens, diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts index c1f62208f..58279fd3d 100644 --- a/src/hooks/directory-agents-injector/hook.ts +++ b/src/hooks/directory-agents-injector/hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { processFilePathForAgentsInjection } from "./injector"; import { clearInjectedPaths } from "./storage"; @@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); diff --git a/src/hooks/directory-readme-injector/hook.ts b/src/hooks/directory-readme-injector/hook.ts index 0fdab1858..a843131b8 100644 --- a/src/hooks/directory-readme-injector/hook.ts +++ b/src/hooks/directory-readme-injector/hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { processFilePathForReadmeInjection } from "./injector"; import { clearInjectedPaths } from "./storage"; @@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); diff --git a/src/hooks/edit-error-recovery/index.test.ts b/src/hooks/edit-error-recovery/index.test.ts index ab8627056..dfee15e89 100644 --- a/src/hooks/edit-error-recovery/index.test.ts +++ b/src/hooks/edit-error-recovery/index.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, beforeEach } from "bun:test" import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("createEditErrorRecoveryHook", () => { let hook: ReturnType beforeEach(() => { - hook = createEditErrorRecoveryHook({} as any) + hook = createEditErrorRecoveryHook(unsafeTestValue({})) }) describe("tool.execute.after", () => { @@ -108,7 +109,7 @@ describe("createEditErrorRecoveryHook", () => { const input = createInput("Edit") const output = { title: "Edit", - output: undefined as unknown as string, + output: unsafeTestValue(undefined), metadata: {}, } diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts index 300a69887..ef042149c 100644 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -1,4 +1,5 @@ import { log } from "../../shared" +import { bunFile } from "../../shared/bun-file-shim" import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils" interface HashlineEditDiffEnhancerConfig { @@ -38,7 +39,7 @@ function extractFilePath(args: Record): string | undefined { async function captureOldContent(filePath: string): Promise { try { - const file = Bun.file(filePath) + const file = bunFile(filePath) if (await file.exists()) { return await file.text() } @@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan let newContent: string try { - newContent = await Bun.file(filePath).text() + newContent = await bunFile(filePath).text() } catch { log("[hashline-edit-diff-enhancer] failed to read new content", { filePath }) return diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 093312d4a..ded243d91 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { bunFile } from "../../shared/bun-file-shim" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" const WRITE_SUCCESS_MARKER = "File written successfully." @@ -178,7 +179,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk return } - const file = Bun.file(filePath) + const file = bunFile(filePath) if (!(await file.exists())) { return } diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts index 4128f903c..86aa2f101 100644 --- a/src/hooks/interactive-bash-session/hook.ts +++ b/src/hooks/interactive-bash-session/hook.ts @@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types"; import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; import { subagentSessions } from "../../features/claude-code-session-state"; +import { resolveSessionEventID } from "../../shared/event-session-id"; interface ToolExecuteInput { tool: string; @@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - const sessionID = sessionInfo?.id; + const sessionID = resolveSessionEventID(props); if (sessionID) { const state = getOrCreateStateLocal(sessionID); diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts index 0cb5972d8..8dcab9c50 100644 --- a/src/hooks/keyword-detector/hook-ralph-loop.test.ts +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { createKeywordDetectorHook } from "./index" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type StartLoopCall = { sessionID: string @@ -11,13 +12,13 @@ type StartLoopCall = { type CancelLoopCall = { sessionID: string } function createMockPluginInput() { - return { + return unsafeTestValue({ client: { tui: { showToast: async () => {}, }, }, - } as any + }) } function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) { diff --git a/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts index 37b938171..8022eb539 100644 --- a/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts +++ b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts @@ -4,6 +4,7 @@ import { createKeywordDetectorHook } from "./index" import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state" import * as sharedModule from "../../shared" import * as sessionState from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("keyword-detector hyperplan-ultrawork combo", () => { let logSpy: ReturnType @@ -22,7 +23,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -30,7 +31,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => { }, }, }, - } as unknown as PluginInput + }) } test("should inject combo message when user types 'hpp ulw' (forward order)", async () => { diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 5d566b674..d1cf485b4 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -7,6 +7,7 @@ import { setMainSession, updateSessionAgent, clearSessionAgent, _resetForTesting import { ContextCollector } from "../../features/context-injector" import * as sharedModule from "../../shared" import * as sessionState from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type ToastOptions = { body: { title: string } } @@ -881,13 +882,13 @@ describe("keyword-detector team mode", () => { }) function createMockPluginInput() { - return { + return unsafeTestValue({ client: { tui: { showToast: async () => {}, }, }, - } as unknown as PluginInput + }) } test("should inject team-mode message when user types 'team mode'", async () => { @@ -1063,7 +1064,7 @@ describe("keyword-detector disabled_keywords config", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -1071,7 +1072,7 @@ describe("keyword-detector disabled_keywords config", () => { }, }, }, - } as unknown as PluginInput + }) } test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => { diff --git a/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts b/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts index f2fbfefa4..8faaa52b8 100644 --- a/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts +++ b/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts @@ -3,6 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createKeywordDetectorHook } from "./index" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type StartLoopCall = { sessionID: string @@ -11,7 +12,7 @@ type StartLoopCall = { } function createMockPluginInput(toastCalls: string[] = []) { - return { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -19,7 +20,7 @@ function createMockPluginInput(toastCalls: string[] = []) { }, }, }, - } as unknown as PluginInput + }) } function createMockRalphLoop(startLoopCalls: StartLoopCall[]) { diff --git a/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts b/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts index 13c8c8943..ec900124c 100644 --- a/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts +++ b/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" import { createKeywordDetectorHook } from "./index" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createMockPluginInput(toastMessages: string[]) { - return { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { message: string } }) => { @@ -11,7 +12,7 @@ function createMockPluginInput(toastMessages: string[]) { }, }, }, - } as any + }) } describe("keyword-detector ultrawork runtime variant gating", () => { diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index e2f2c850f..550c8cdbd 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -1,3 +1,4 @@ +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" declare const require: (name: string) => any const { beforeEach, describe, expect, mock, test, afterAll } = require("bun:test") @@ -86,12 +87,12 @@ describe("model fallback hook", () => { }) test("applies pending fallback on chat.message by overriding model", async () => { - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) const set = setPendingModelFallback( modelFallback, @@ -122,12 +123,12 @@ describe("model fallback hook", () => { }) test("preserves fallback progression across repeated session.error retries", async () => { - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) const sessionID = "ses_model_fallback_main" expect( @@ -212,12 +213,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) setSessionFallbackChain(modelFallback, sessionID, [ { providers: ["anthropic"], model: "claude-opus-4-7" }, @@ -254,12 +255,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_variant_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) setSessionFallbackChain(modelFallback, sessionID, [ { providers: ["quotio"], model: "claude-opus-4-7", variant: "max" }, @@ -299,12 +300,12 @@ describe("model fallback hook", () => { clearPendingModelFallback(modelFallback, sessionID) readConnectedProvidersCacheMock.mockReturnValue(["provider-x"]) - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) setSessionFallbackChain(modelFallback, sessionID, [ { providers: ["provider-y"], model: "fallback-model" }, @@ -355,16 +356,16 @@ describe("model fallback hook", () => { test("shows toast when fallback is applied", async () => { const toastCalls: Array<{ title: string; message: string }> = [] - const hook = createModelFallbackHook({ - toast: async ({ title, message }) => { - toastCalls.push({ title, message }) - }, - }) as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(createModelFallbackHook({ + toast: async ({ title, message }) => { + toastCalls.push({ title, message }) + }, + })) const set = setPendingModelFallback( hook, @@ -393,12 +394,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_ghcp" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) setSessionFallbackChain(modelFallback, sessionID, [ { providers: ["github-copilot"], model: "claude-sonnet-4-6" }, @@ -434,12 +435,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_google" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) setSessionFallbackChain(modelFallback, sessionID, [ { providers: ["google"], model: "gemini-3.1-pro-preview" }, diff --git a/src/hooks/no-hephaestus-non-gpt/index.test.ts b/src/hooks/no-hephaestus-non-gpt/index.test.ts index 6ca505f3c..c28f65bf4 100644 --- a/src/hooks/no-hephaestus-non-gpt/index.test.ts +++ b/src/hooks/no-hephaestus-non-gpt/index.test.ts @@ -4,6 +4,7 @@ import { describe, expect, spyOn, test } from "bun:test" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { getAgentDisplayName } from "../../shared/agent-display-names" import { createNoHephaestusNonGptHook } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") @@ -19,9 +20,9 @@ describe("no-hephaestus-non-gpt hook", () => { test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => { // given - hephaestus with claude model const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, - } as any) + })) const output1 = createOutput() const output2 = createOutput() @@ -54,9 +55,9 @@ describe("no-hephaestus-non-gpt hook", () => { test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => { // given - hephaestus with claude model and opt-out enabled const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, - } as any, { + }), { allowNonGptModel: true, }) @@ -83,9 +84,9 @@ describe("no-hephaestus-non-gpt hook", () => { test("does not show toast when hephaestus uses gpt model", async () => { // given - hephaestus with gpt model const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, - } as any) + })) const output = createOutput() @@ -104,9 +105,9 @@ describe("no-hephaestus-non-gpt hook", () => { test("does not show toast for non-hephaestus agent", async () => { // given - sisyphus with claude model (non-gpt) const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, - } as any) + })) const output = createOutput() @@ -127,9 +128,9 @@ describe("no-hephaestus-non-gpt hook", () => { _resetForTesting() updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, - } as any) + })) const output = createOutput() diff --git a/src/hooks/no-sisyphus-gpt/index.test.ts b/src/hooks/no-sisyphus-gpt/index.test.ts index 125c57432..81565fe44 100644 --- a/src/hooks/no-sisyphus-gpt/index.test.ts +++ b/src/hooks/no-sisyphus-gpt/index.test.ts @@ -5,6 +5,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { getAgentDisplayName } from "../../shared/agent-display-names" import { createNoSisyphusGptHook } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") @@ -22,9 +23,9 @@ function createOutput(): HookOutput { } function createHookContext(showToast: (input: unknown) => Promise): PluginInput { - return { + return unsafeTestValue({ client: { tui: { showToast } }, - } as unknown as PluginInput + }) } describe("no-sisyphus-gpt hook", () => { diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index b1e46b689..28da76192 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,5 +1,6 @@ import type { OhMyOpenCodeConfig } from "../config" import { isCompactionAgent } from "../shared/compaction-marker" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" @@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionID = (props?.info as { id?: string } | undefined)?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { compactionInProgress.delete(sessionID) compactedSessions.delete(sessionID) @@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook( } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID as string | undefined) - ?? (props?.info as { id?: string } | undefined)?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { postCompactionMonitor.onSessionCompacted(sessionID) } @@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook( parts?: unknown } | undefined - if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return + const sessionID = resolveMessageEventSessionID(props) + if (!info || info.role !== "assistant" || !info.finish || !sessionID) return if (isCompactionAgent(info.agent)) return if (info.providerID && info.tokens) { - tokenCache.set(info.sessionID, { + tokenCache.set(sessionID, { providerID: info.providerID, modelID: info.modelID ?? "", tokens: info.tokens, }) } - compactedSessions.delete(info.sessionID) + compactedSessions.delete(sessionID) await postCompactionMonitor.onAssistantMessageUpdated({ - sessionID: info.sessionID, + sessionID, id: info.id, parts: info.parts, }) diff --git a/src/hooks/question-label-truncator/hook.ts b/src/hooks/question-label-truncator/hook.ts index 03e72b23c..a43fa4fc7 100644 --- a/src/hooks/question-label-truncator/hook.ts +++ b/src/hooks/question-label-truncator/hook.ts @@ -41,6 +41,10 @@ function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs }; } +function hasQuestions(args: Record): args is Record & AskUserQuestionArgs { + return Array.isArray(args.questions); +} + export function createQuestionLabelTruncatorHook() { return { "tool.execute.before": async ( @@ -50,10 +54,8 @@ export function createQuestionLabelTruncatorHook() { const toolName = input.tool?.toLowerCase(); if (toolName === "askuserquestion" || toolName === "ask_user_question") { - const args = output.args as unknown as AskUserQuestionArgs | undefined; - - if (args?.questions) { - const truncatedArgs = truncateQuestionLabels(args); + if (hasQuestions(output.args)) { + const truncatedArgs = truncateQuestionLabels(output.args); Object.assign(output.args, truncatedArgs); } } diff --git a/src/hooks/question-label-truncator/index.test.ts b/src/hooks/question-label-truncator/index.test.ts index 520bd74ae..bbb666caf 100644 --- a/src/hooks/question-label-truncator/index.test.ts +++ b/src/hooks/question-label-truncator/index.test.ts @@ -1,3 +1,4 @@ +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" import { describe, it, expect } from "bun:test"; import { createQuestionLabelTruncatorHook } from "./index"; @@ -23,10 +24,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const truncatedLabel = (output.args as any).questions[0].options[0].label; + const truncatedLabel = (unsafeTestValue(output.args)).questions[0].options[0].label; expect(truncatedLabel.length).toBeLessThanOrEqual(30); expect(truncatedLabel).toBe("This is a very long label t..."); expect(truncatedLabel.endsWith("...")).toBe(true); @@ -50,10 +51,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const resultLabel = (output.args as any).questions[0].options[0].label; + const resultLabel = (unsafeTestValue(output.args)).questions[0].options[0].label; expect(resultLabel).toBe(shortLabel); }); @@ -74,10 +75,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const resultLabel = (output.args as any).questions[0].options[0].label; + const resultLabel = (unsafeTestValue(output.args)).questions[0].options[0].label; expect(resultLabel).toBe(exactLabel); }); @@ -90,7 +91,7 @@ describe("createQuestionLabelTruncatorHook", () => { const originalArgs = { ...output.args }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then expect(output.args).toEqual(originalArgs); @@ -120,11 +121,11 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const q1opts = (output.args as any).questions[0].options; - const q2opts = (output.args as any).questions[1].options; + const q1opts = (unsafeTestValue(output.args)).questions[0].options; + const q2opts = (unsafeTestValue(output.args)).questions[1].options; expect(q1opts[0].label).toBe("Very long label number one ..."); expect(q1opts[0].label.length).toBeLessThanOrEqual(30); diff --git a/src/hooks/ralph-loop/completion-handler.ts b/src/hooks/ralph-loop/completion-handler.ts index 740e59695..e7bbb9aed 100644 --- a/src/hooks/ralph-loop/completion-handler.ts +++ b/src/hooks/ralph-loop/completion-handler.ts @@ -10,6 +10,16 @@ type LoopStateController = { markVerificationPending: (sessionID: string) => RalphLoopState | null } +function showToastBestEffort( + ctx: PluginInput, + body: { title: string; message: string; variant: "error" | "info" | "success"; duration: number }, +): void { + try { + void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {}) + } catch { + } +} + export async function handleDetectedCompletion( ctx: PluginInput, input: { @@ -35,21 +45,33 @@ export async function handleDetectedCompletion( return } - await injectContinuationPrompt(ctx, { + const promptResult = await injectContinuationPrompt(ctx, { sessionID, prompt: buildContinuationPrompt(verificationState), directory, apiTimeoutMs, }) - - await ctx.client.tui?.showToast?.({ - body: { - title: "ULTRAWORK LOOP", - message: "DONE detected. Oracle verification is now required.", - variant: "info", + if (promptResult.status === "rejected") { + log(`[${HOOK_NAME}] Failed to inject ultrawork verification prompt`, { + sessionID, + error: String(promptResult.error), + }) + loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: `Verification dispatch rejected: ${String(promptResult.error)}`, + variant: "error", duration: 5000, - }, - }).catch(() => {}) + }) + return + } + + showToastBestEffort(ctx, { + title: "ULTRAWORK LOOP", + message: "DONE detected. Oracle verification is now required.", + variant: "info", + duration: 5000, + }) return } @@ -59,7 +81,5 @@ export async function handleDetectedCompletion( const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)` - await ctx.client.tui?.showToast?.({ - body: { title, message, variant: "success", duration: 5000 }, - }).catch(() => {}) + showToastBestEffort(ctx, { title, message, variant: "success", duration: 5000 }) } diff --git a/src/hooks/ralph-loop/completion-promise-detector-test-input.ts b/src/hooks/ralph-loop/completion-promise-detector-test-input.test.ts similarity index 68% rename from src/hooks/ralph-loop/completion-promise-detector-test-input.ts rename to src/hooks/ralph-loop/completion-promise-detector-test-input.test.ts index 4a1e6c259..ca4e4b8a1 100644 --- a/src/hooks/ralph-loop/completion-promise-detector-test-input.ts +++ b/src/hooks/ralph-loop/completion-promise-detector-test-input.test.ts @@ -1,5 +1,6 @@ /// import type { PluginInput } from "@opencode-ai/plugin" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" export type SessionMessage = { info?: { role?: string } @@ -16,8 +17,8 @@ export function createPluginInput(messages: SessionMessage[]): PluginInput { $: {} as PluginInput["$"], } as PluginInput - pluginInput.client.session.messages = - (async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"] + const messagesFunction = unsafeTestValue(async () => ({ data: messages })) + pluginInput.client.session.messages = messagesFunction return pluginInput } diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts index 814684068..2241ddcd0 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.test.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts @@ -1,7 +1,7 @@ /// import { describe, expect, test } from "bun:test" import { detectCompletionInSessionMessages } from "./completion-promise-detector" -import { createPluginInput } from "./completion-promise-detector-test-input" +import { createPluginInput } from "./completion-promise-detector-test-input.test" describe("detectCompletionInSessionMessages", () => { describe("#given session with prior DONE and new messages", () => { @@ -58,6 +58,29 @@ describe("detectCompletionInSessionMessages", () => { // #then expect(detected).toBe(true) }) + + test("#when sinceMessageIndex equals current message count #then should NOT rescan old DONE", async () => { + // #given + const messages = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Old completion DONE" }], + }, + ] + const ctx = createPluginInput(messages) + + // #when + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "DONE", + apiTimeoutMs: 1000, + directory: "/tmp", + sinceMessageIndex: messages.length, + }) + + // #then + expect(detected).toBe(false) + }) }) describe("#given no sinceMessageIndex (backward compat)", () => { diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index 65718e67e..6c915a2c3 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -130,8 +130,8 @@ export async function detectCompletionInSessionMessages( : [] const scopedMessages = - typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length - ? messageArray.slice(options.sinceMessageIndex) + typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 + ? messageArray.slice(Math.min(options.sinceMessageIndex, messageArray.length)) : messageArray const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") diff --git a/src/hooks/ralph-loop/completion-promise-session-negative.test.ts b/src/hooks/ralph-loop/completion-promise-session-negative.test.ts index 7acd7f879..d33059e05 100644 --- a/src/hooks/ralph-loop/completion-promise-session-negative.test.ts +++ b/src/hooks/ralph-loop/completion-promise-session-negative.test.ts @@ -1,7 +1,7 @@ /// import { describe, expect, test } from "bun:test" import { detectCompletionInSessionMessages } from "./completion-promise-detector" -import { createPluginInput } from "./completion-promise-detector-test-input" +import { createPluginInput } from "./completion-promise-detector-test-input.test" describe("detectCompletionInSessionMessages negative cases", () => { describe("#given natural language completion text without explicit promise", () => { diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.test.ts b/src/hooks/ralph-loop/continuation-prompt-injector.test.ts index 6455e70e1..bc70c859a 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.test.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.test.ts @@ -2,6 +2,63 @@ import { describe, expect, test } from "bun:test" import { injectContinuationPrompt } from "./continuation-prompt-injector" describe("ralph-loop continuation prompt injector", () => { + test("#given promptAsync resolves SDK error #when injecting continuation prompt #then it returns rejection without throwing", async () => { + // given + const ctx = { + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async () => ({ + error: { message: "prompt rejected by OpenCode" }, + response: { status: 400 }, + }), + }, + }, + } + + // when + const result = await injectContinuationPrompt(ctx as never, { + sessionID: "ses_rejected_fields_response", + prompt: "continue", + directory: "/tmp/test", + apiTimeoutMs: 50, + }) + + // then + expect(result.status).toBe("rejected") + if (result.status === "rejected") { + expect(String(result.error)).toContain("prompt rejected by OpenCode") + } + }) + + test("#given promptAsync rejects #when injecting continuation prompt #then it returns rejection without throwing", async () => { + // given + const ctx = { + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async () => { + throw new Error("network rejected promptAsync") + }, + }, + }, + } + + // when + const result = await injectContinuationPrompt(ctx as never, { + sessionID: "ses_rejected_promise", + prompt: "continue", + directory: "/tmp/test", + apiTimeoutMs: 50, + }) + + // then + expect(result.status).toBe("rejected") + if (result.status === "rejected") { + expect(String(result.error)).toContain("network rejected promptAsync") + } + }) + test("#given inherited message agent has ZWSP prefix #when injecting continuation prompt #then promptAsync receives normalized agent", async () => { // given let promptBody: { agent?: string } | undefined diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index fc1405c74..4fd66c7d1 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -5,6 +5,7 @@ import { getMessageDir } from "./message-storage-directory" import { withTimeout } from "./with-timeout" import { createInternalAgentTextPart, + isRecord, normalizeSDKResponse, resolveInheritedPromptTools, } from "../../shared" @@ -18,6 +19,45 @@ type MessageInfo = { tools?: Record } +export type ContinuationPromptResult = + | { status: "dispatched" } + | { status: "rejected"; error: Error } + +function extractPromptAsyncError(response: unknown): unknown | undefined { + if (!isRecord(response) || !Object.hasOwn(response, "error")) { + return undefined + } + + return response.error ?? "Unknown promptAsync error" +} + +function describePromptAsyncError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === "string") { + return error + } + + if (isRecord(error)) { + const message = error.message + if (typeof message === "string") { + return message + } + } + + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function createPromptAsyncError(prefix: string, error: unknown): Error { + return new Error(`${prefix}: ${describePromptAsyncError(error)}`) +} + export async function injectContinuationPrompt( ctx: PluginInput, options: { @@ -27,7 +67,7 @@ export async function injectContinuationPrompt( apiTimeoutMs: number inheritFromSessionID?: string }, -): Promise { +): Promise { let agent: string | undefined let model: { providerID: string; modelID: string; variant?: string } | undefined let tools: Record | undefined @@ -77,17 +117,39 @@ export async function injectContinuationPrompt( : undefined const launchVariant = model?.variant - await ctx.client.session.promptAsync({ - path: { id: options.sessionID }, - body: { - ...(cleanAgent !== undefined ? { agent: cleanAgent } : {}), - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - ...(inheritedTools ? { tools: inheritedTools } : {}), - parts: [createInternalAgentTextPart(options.prompt)], - }, - query: { directory: options.directory }, - }) + let response: unknown + try { + response = await ctx.client.session.promptAsync({ + path: { id: options.sessionID }, + body: { + ...(cleanAgent !== undefined ? { agent: cleanAgent } : {}), + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + ...(inheritedTools ? { tools: inheritedTools } : {}), + parts: [createInternalAgentTextPart(options.prompt)], + }, + query: { directory: options.directory }, + }) + } catch (error) { + const promptError = error instanceof Error + ? error + : createPromptAsyncError("promptAsync rejected", error) + log("[ralph-loop] continuation prompt rejected", { + sessionID: options.sessionID, + error: String(promptError), + }) + return { status: "rejected", error: promptError } + } + const promptError = extractPromptAsyncError(response) + if (promptError !== undefined) { + const error = createPromptAsyncError("promptAsync returned error", promptError) + log("[ralph-loop] continuation prompt rejected", { + sessionID: options.sessionID, + error: String(error), + }) + return { status: "rejected", error } + } log("[ralph-loop] continuation injected", { sessionID: options.sessionID }) + return { status: "dispatched" } } diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts index 096a01aad..8e8d99a12 100644 --- a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { createRalphLoopHook } from "./index" import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { handleDetectedCompletion } from "./completion-handler" import { clearState, writeState } from "./storage" import { handleFailedVerification } from "./verification-failure-handler" @@ -77,6 +78,53 @@ describe("ralph-loop dispatch failure invariants", () => { expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) }) + test("#given idle path #when promptAsync resolves SDK error #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => ({ + error: { message: "prompt rejected by OpenCode" }, + response: { status: 400 }, + }), + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("prompt rejected by OpenCode"))).toBe(true) + }) + test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => { // given const hook = createRalphLoopHook({ @@ -198,6 +246,78 @@ describe("ralph-loop dispatch failure invariants", () => { expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true) }) + test("#given verification-failure path #when promptAsync resolves SDK error #then continuation toast is not shown", async () => { + // given + const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl") + const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl") + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + if (options.path.id === "session-123") { + return { data: [{}, {}, {}] } + } + return { data: [] } + }, + promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => { + if (options.body.parts[0]?.text.includes("Verification failed")) { + return { + error: { message: "verification continuation rejected by OpenCode" }, + response: { status: 400 }, + } + } + return {} + }, + prompt: async () => ({}), + abort: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, + }) + + hook.startLoop("session-123", "Build API", { ultrawork: true }) + writeState(testDirectory, { + ...hook.getState()!, + iteration: 2, + verification_pending: true, + verification_session_id: "ses-oracle", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + initial_completion_promise: "DONE", + }) + writeFileSync( + oracleTranscriptPath, + `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`, + ) + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } }) + + // then + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false) + expect( + toastCalls.some( + (toast) => + toast.title === "Ralph Loop Failed" + && toast.message.includes("verification continuation rejected by OpenCode"), + ), + ).toBe(true) + }) + test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => { // given const hook = createRalphLoopHook({ @@ -364,6 +484,75 @@ describe("ralph-loop dispatch failure invariants", () => { ).toBe(true) }) + test("#given ultrawork completion path #when verification prompt resolves SDK error #then oracle-required toast is not shown", async () => { + // given + let cleared = false + const loopState = { + clear: () => { + cleared = true + return true + }, + markVerificationPending: (sessionID: string) => ({ + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: sessionID, + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + verification_pending: true, + }), + } + + await handleDetectedCompletion({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async () => ({ + error: { message: "verification prompt rejected by OpenCode" }, + response: { status: 400 }, + }), + abort: async () => ({}), + }, + tui: { + showToast: (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + }, + }, + }, + } as never, { + sessionID: "session-123", + state: { + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: "session-123", + completion_promise: "DONE", + ultrawork: true, + }, + loopState, + directory: testDirectory, + apiTimeoutMs: 5000, + }) + + // then + expect(cleared).toBe(true) + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false) + expect( + toastCalls.some( + (toast) => + toast.title === "Ralph Loop Failed" + && toast.message.includes("verification prompt rejected by OpenCode"), + ), + ).toBe(true) + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.variant === "error")).toBe(true) + }) + test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => { // given const hook = createRalphLoopHook({ diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 4bda8eb0b..d131744b7 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -288,7 +288,7 @@ describe("ralph-loop", () => { await hook.event({ event: { type: "session.idle", - properties: { sessionID: "session-123" }, + properties: { sessionID: "session-123", synthetic: true }, }, }) @@ -304,6 +304,81 @@ describe("ralph-loop", () => { expect(state?.iteration).toBe(2) }) + test("#given synthetic and real idle arrive back-to-back #then only one continuation is injected for the same iteration", async () => { + // given + const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 }) + hook.startLoop("session-123", "Build a feature", { maxIterations: 10 }) + + // when + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123", synthetic: true }, + }, + }) + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + + // then + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].sessionID).toBe("session-123") + expect(hook.getState()?.iteration).toBe(2) + }) + + test("#given new activity after an idle continuation #when session idles again #then next iteration can continue", async () => { + // given + const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 }) + hook.startLoop("session-123", "Build a feature", { maxIterations: 10 }) + + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + + // when + await hook.event({ + event: { + type: "message.part.updated", + properties: { sessionID: "session-123" }, + }, + }) + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + + // then + expect(promptCalls.length).toBe(2) + expect(hook.getState()?.iteration).toBe(3) + }) + + test("should inject continuation when idle event carries session id in info", async () => { + // given - active loop state and nested session event shape + const hook = createRalphLoopHook(createMockPluginInput()) + hook.startLoop("session-info-idle", "Build a feature", { maxIterations: 10 }) + + // when - session goes idle with id under info + await hook.event({ + event: { + type: "session.idle", + properties: { info: { id: "session-info-idle" } }, + }, + }) + + // then - continuation should be injected for that session + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].sessionID).toBe("session-info-idle") + expect(promptCalls[0].text).toContain("RALPH LOOP") + }) + test("should settle idle before injecting continuation", async () => { // given - active loop state with a configured idle settle delay const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 }) diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index af43955fa..00c5c878b 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -39,13 +39,16 @@ export async function continueIteration( } try { - await injectContinuationPrompt(ctx, { + const promptResult = await injectContinuationPrompt(ctx, { sessionID: newSessionID, inheritFromSessionID: options.previousSessionID, prompt: continuationPrompt, directory: options.directory, apiTimeoutMs: options.apiTimeoutMs, }) + if (promptResult.status === "rejected") { + return { status: "dispatch_rejected", error: promptResult.error } + } } catch (error: unknown) { return { status: "dispatch_rejected", error } } @@ -65,12 +68,15 @@ export async function continueIteration( } try { - await injectContinuationPrompt(ctx, { + const promptResult = await injectContinuationPrompt(ctx, { sessionID: options.previousSessionID, prompt: continuationPrompt, directory: options.directory, apiTimeoutMs: options.apiTimeoutMs, }) + if (promptResult.status === "rejected") { + return { status: "dispatch_rejected", error: promptResult.error } + } } catch (error: unknown) { return { status: "dispatch_rejected", error } } diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index 5c5f63ca7..66c51f96f 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => { expect(hook.getState()?.iteration).toBe(3) }) + test("continues after retry run activity from legacy message.part.updated part session id", async () => { + // given - an active loop retries a recoverable runtime error + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // when - the retried run emits legacy assistant activity before any stale idle + await hook.event({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "session-123", + type: "text", + text: "working", + }, + }, + }, + }) + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then - the real idle is allowed to continue the loop + expect(promptCalls).toHaveLength(2) + expect(hook.getState()?.iteration).toBe(3) + }) + test("skips immediate runtime retry while background tasks are running", async () => { // given - an active loop owns running background work const hook = createRalphLoopHook({ diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 87c0f9435..ddbef7fe6 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import type { RalphLoopOptions, RalphLoopState } from "./types" import { HOOK_NAME } from "./constants" import { handleDetectedCompletion } from "./completion-handler" @@ -11,6 +12,8 @@ import { continueIteration } from "./iteration-continuation" import { handlePendingVerification } from "./pending-verification-handler" import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler" +const RAPID_IDLE_DEDUP_MS = 500 + type LoopStateController = { getState: () => RalphLoopState | null clear: () => boolean @@ -36,12 +39,6 @@ function hasRunningBackgroundTasks( : false } -function getInfoSessionID(props: Record | undefined): string | undefined { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID - return typeof sessionID === "string" ? sessionID : undefined -} - function getRuntimeRetryActivitySessionID( eventType: string, props: Record | undefined, @@ -49,25 +46,28 @@ function getRuntimeRetryActivitySessionID( if (eventType === "message.updated") { const info = props?.info as Record | undefined const role = info?.role - return role === "assistant" ? getInfoSessionID(props) : undefined + return role === "assistant" ? resolveMessageEventSessionID(props) : undefined } if (eventType === "message.part.updated") { - if (typeof props?.sessionID === "string") return props.sessionID - return getInfoSessionID(props) + return resolveMessageEventSessionID(props) } if (eventType === "message.part.delta") { - return typeof props?.sessionID === "string" ? props.sessionID : undefined + return resolveMessageEventSessionID(props) } if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { - return typeof props?.sessionID === "string" ? props.sessionID : undefined + return resolveMessageEventSessionID(props) } return undefined } +function isSyntheticIdle(props: Record | undefined): boolean { + return props?.synthetic === true +} + function isAbortError(error: unknown): boolean { return typeof error === "object" && error !== null @@ -189,17 +189,20 @@ export function createRalphLoopEventHandler( ) { const inFlightSessions = new Set() const runtimeErrorRetriedSessions = new Map() + const recentHandledSyntheticIdleAt = new Map() return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { const props = event.properties as Record | undefined const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props) if (runtimeRetryActivitySessionID) { runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID) + recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID) } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return + const syntheticIdle = isSyntheticIdle(props) if (inFlightSessions.has(sessionID)) { log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID }) @@ -247,6 +250,17 @@ export function createRalphLoopEventHandler( return } + const lastHandledSyntheticIdleAt = recentHandledSyntheticIdleAt.get(sessionID) + const now = Date.now() + if (!syntheticIdle && lastHandledSyntheticIdleAt !== undefined && now - lastHandledSyntheticIdleAt < RAPID_IDLE_DEDUP_MS) { + recentHandledSyntheticIdleAt.delete(sessionID) + log(`[${HOOK_NAME}] Skipped: duplicate real idle after synthetic idle`, { sessionID }) + return + } + if (syntheticIdle) { + recentHandledSyntheticIdleAt.set(sessionID, now) + } + if (await handleCompletionIfDetected(ctx, options, { sessionID, state, @@ -389,7 +403,7 @@ export function createRalphLoopEventHandler( } if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error if (!sessionID || isAbortError(error)) { handleErroredLoopSession(props, options.loopState) diff --git a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts index 15de66084..99e09ab51 100644 --- a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts +++ b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts @@ -1,6 +1,7 @@ /// import { describe, expect, test } from "bun:test" import { createRalphLoopHook } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createDeferred(): { promise: Promise @@ -44,7 +45,7 @@ describe("ralph-loop reset strategy race condition", () => { const selectSessionDeferred = createDeferred() const hook = createRalphLoopHook( - { + unsafeTestValue[0]>({ directory: process.cwd(), client: { session: { @@ -86,7 +87,7 @@ describe("ralph-loop reset strategy race condition", () => { }, }, }, - } as unknown as Parameters[0], + }), { idleSettleMs: 0 }, ) diff --git a/src/hooks/ralph-loop/session-event-handler.ts b/src/hooks/ralph-loop/session-event-handler.ts index 193a89de9..ee85e7f3a 100644 --- a/src/hooks/ralph-loop/session-event-handler.ts +++ b/src/hooks/ralph-loop/session-event-handler.ts @@ -1,4 +1,5 @@ import { log } from "../../shared/logger" +import { resolveSessionEventID } from "../../shared/event-session-id" import { HOOK_NAME } from "./constants" import type { RalphLoopState } from "./types" @@ -11,13 +12,13 @@ export function handleDeletedLoopSession( props: Record | undefined, loopState: LoopStateController, ): boolean { - const sessionInfo = props?.info as { id?: string } | undefined - if (!sessionInfo?.id) return false + const sessionID = resolveSessionEventID(props) + if (!sessionID) return false const state = loopState.getState() - if (state?.session_id === sessionInfo.id) { + if (state?.session_id === sessionID) { loopState.clear() - log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) + log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID }) } return true } @@ -26,7 +27,7 @@ export function handleErroredLoopSession( props: Record | undefined, loopState: LoopStateController, ): boolean { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error as { name?: string } | undefined if (error?.name === "MessageAbortedError") { diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index 54041f452..2c7499b96 100644 --- a/src/hooks/ralph-loop/ulw-loop-verification.test.ts +++ b/src/hooks/ralph-loop/ulw-loop-verification.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { createRalphLoopHook } from "./index" import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" import { clearState, writeState } from "./storage" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("ulw-loop verification", () => { const testDir = join(tmpdir(), `ulw-loop-verification-${Date.now()}`) @@ -15,7 +16,7 @@ describe("ulw-loop verification", () => { let oracleTranscriptPath: string function createMockPluginInput() { - return { + return unsafeTestValue[0]>({ client: { session: { promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { @@ -39,7 +40,7 @@ describe("ulw-loop verification", () => { }, }, directory: testDir, - } as unknown as Parameters[0] + }) } beforeEach(() => { diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index 89917f033..a14ccc846 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -98,12 +98,26 @@ export async function handleFailedVerification( const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 } try { - await injectContinuationPrompt(ctx, { + const promptResult = await injectContinuationPrompt(ctx, { sessionID: parentSessionID, prompt: buildVerificationFailurePrompt(previewState), directory, apiTimeoutMs, }) + if (promptResult.status === "rejected") { + log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, { + parentSessionID, + error: String(promptResult.error), + }) + loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: `Verification continuation rejected: ${String(promptResult.error)}`, + variant: "warning", + duration: 5000, + }) + return false + } } catch (error) { log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, { parentSessionID, diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts index b3a09ca12..3b62d5e01 100644 --- a/src/hooks/rules-injector/hook.ts +++ b/src/hooks/rules-injector/hook.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { getRuleInjectionFilePath } from "./output-path"; import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; import { createRuleInjectionProcessor } from "./injector"; @@ -80,16 +81,15 @@ export function createRulesInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - clearSessionState(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + clearSessionState(sessionID); } clearProjectRootCache(); } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { clearSessionState(sessionID); } diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 5776041ed..d874c5c15 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { createSessionStatusHandler } from "./session-status-handler" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps @@ -30,7 +31,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const handleSessionCreated = (props: Record | undefined) => { const sessionInfo = props?.info as { id?: string; model?: string } | undefined - const sessionID = sessionInfo?.id + const sessionID = resolveSessionEventID(props) const model = sessionInfo?.model if (sessionID && model) { @@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionDeleted = (props: Record | undefined) => { - const sessionInfo = props?.info as { id?: string } | undefined - const sessionID = sessionInfo?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID }) @@ -58,7 +58,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionStop = async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) { @@ -73,7 +73,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const handleMessageUpdated = (props: Record | undefined) => { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID || role !== "user") return @@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionIdle = (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (cancelledSessions.has(sessionID)) { @@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionError = async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error const agent = props?.agent as string | undefined diff --git a/src/hooks/runtime-fallback/fallback-models.test.ts b/src/hooks/runtime-fallback/fallback-models.test.ts index ebfa8fbc9..0946a2452 100644 --- a/src/hooks/runtime-fallback/fallback-models.test.ts +++ b/src/hooks/runtime-fallback/fallback-models.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { getFallbackModelsForSession } from "./fallback-models" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("runtime-fallback fallback-models", () => { afterEach(() => { @@ -12,13 +13,13 @@ describe("runtime-fallback fallback-models", () => { //#given const sessionID = "ses_runtime_fallback_category" SessionCategoryRegistry.register(sessionID, "quick") - const pluginConfig = { + const pluginConfig = unsafeTestValue({ categories: { quick: { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], }, }, - } as any + }) //#when const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig) @@ -29,13 +30,13 @@ describe("runtime-fallback fallback-models", () => { test("uses agent-specific fallback_models when agent is resolved", () => { //#given - const pluginConfig = { + const pluginConfig = unsafeTestValue({ agents: { oracle: { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], }, }, - } as any + }) //#when const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig) @@ -46,7 +47,7 @@ describe("runtime-fallback fallback-models", () => { test("does not fall back to another agent chain when agent cannot be resolved", () => { //#given - const pluginConfig = { + const pluginConfig = unsafeTestValue({ agents: { sisyphus: { fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"], @@ -55,7 +56,7 @@ describe("runtime-fallback fallback-models", () => { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], }, }, - } as any + }) //#when const result = getFallbackModelsForSession("ses_runtime_fallback_unknown", undefined, pluginConfig) diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index d96f6d211..45c52bcbe 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test" import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" import * as loggerModule from "../../shared/logger" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type RuntimeFallbackModule = typeof import("./hook") @@ -41,7 +42,7 @@ describe("runtime-fallback", () => { abort?: (args: unknown) => Promise } }) { - return { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => { @@ -59,7 +60,7 @@ describe("runtime-fallback", () => { }, }, directory: "/test/dir", - } as any + }) } function createMockConfig(overrides?: Partial): RuntimeFallbackConfig { diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 7382ebd2e..dbca6a4b4 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -9,6 +9,7 @@ import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { hasVisibleAssistantResponse } from "./visible-assistant-response" import { subagentSessions } from "../../features/claude-code-session-state" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" export { hasVisibleAssistantResponse } from "./visible-assistant-response" @@ -18,7 +19,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel return async (props: Record | undefined) => { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const timeoutEnabled = config.timeout_seconds > 0 const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined diff --git a/src/hooks/runtime-fallback/session-status-handler.ts b/src/hooks/runtime-fallback/session-status-handler.ts index 1fff2a6ff..c356f2f6f 100644 --- a/src/hooks/runtime-fallback/session-status-handler.ts +++ b/src/hooks/runtime-fallback/session-status-handler.ts @@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models" import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" +import { resolveSessionEventID } from "../../shared/event-session-id" export function createSessionStatusHandler( deps: HookDeps, @@ -22,7 +23,7 @@ export function createSessionStatusHandler( } = deps return async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined const agent = props?.agent as string | undefined const model = props?.model as string | undefined diff --git a/src/hooks/session-notification-event-properties.ts b/src/hooks/session-notification-event-properties.ts index b51edf81b..e3e4205dc 100644 --- a/src/hooks/session-notification-event-properties.ts +++ b/src/hooks/session-notification-event-properties.ts @@ -23,6 +23,15 @@ export function getSessionID(properties: EventProperties): string | undefined { const infoSessionId = info?.sessionId if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId + const part = properties?.part + if (isRecord(part)) { + const partSessionID = part.sessionID + if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID + + const partSessionId = part.sessionId + if (typeof partSessionId === "string" && partSessionId.length > 0) return partSessionId + } + return undefined } diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts index 931443b4b..99b08672a 100644 --- a/src/hooks/session-notification-sender.test.ts +++ b/src/hooks/session-notification-sender.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun: import * as sender from "./session-notification-sender" import * as utils from "./session-notification-utils" import type { PluginInput } from "@opencode-ai/plugin" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" @@ -80,7 +81,7 @@ describe("session-notification-sender", () => { describe("#when calling ctx.$ for notifications", () => { test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -95,7 +96,7 @@ describe("session-notification-sender", () => { promise.nothrow = () => promise return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -107,7 +108,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -130,7 +131,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -142,9 +143,9 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const calls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -157,9 +158,9 @@ describe("session-notification-sender", () => { test("#then should fall back to terminal-notifier when cmux fails", async () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") - const mockCtx = { + const mockCtx = unsafeTestValue({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")), - } as unknown as PluginInput + }) const originalFactory = mockCtx.$ const trackingCalls: string[] = [] @@ -180,9 +181,9 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const trackingCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")), - } as unknown as PluginInput + }) const originalFactory = mockCtx.$ mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { @@ -200,9 +201,9 @@ describe("session-notification-sender", () => { test("#then should skip cmux when not available and use terminal-notifier", async () => { const calls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -213,7 +214,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux notify-send", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -236,7 +237,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "linux", "Test", "Message") @@ -246,7 +247,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -269,7 +270,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message") @@ -283,7 +284,7 @@ describe("session-notification-sender", () => { describe("#when calling ctx.$ for sound playback", () => { test("#then should call .quiet() on darwin afplay", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -306,7 +307,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff") @@ -316,7 +317,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux paplay", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -339,7 +340,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") @@ -351,7 +352,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getPaplayPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -374,7 +375,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") @@ -384,7 +385,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell sound", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = unsafeTestValue({ $: (cmd: TemplateStringsArray, ...values: unknown[]) => { const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } @@ -407,7 +408,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "win32", "C:\\sound.wav") diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index b3eb8e32d..0c690dca3 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -1,14 +1,11 @@ import { log } from "../shared/logger" - -declare const Bun: { - which(commandName: string): string | null -} +import { bunWhich } from "../shared/bun-which-shim" type Platform = "darwin" | "linux" | "win32" | "unsupported" async function findCommand(commandName: string): Promise { try { - return Bun.which(commandName) + return bunWhich(commandName) } catch (error) { log("[session-notification] failed to resolve command path", { commandName, diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index ceb2c981b..31a133f9a 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -375,6 +375,47 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(0) }) + test("should mark session activity on message.part.updated event with part session id", async () => { + // given - main session is set + const mainSessionID = "main-part-activity" + setMainSession(mainSessionID) + + const hook = createSessionNotification(createMockPluginInput(), { + idleConfirmationDelay: 50, + skipIfIncompleteTodos: false, + activityGracePeriodMs: 0, + }) + + // when - session goes idle, then streamed assistant activity fires + await hook({ + event: { + type: "session.idle", + properties: { sessionID: mainSessionID }, + }, + }) + + await hook({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: mainSessionID, + type: "text", + text: "still working", + }, + }, + }, + }) + + // Wait for idle delay to pass + await new Promise((resolve) => setTimeout(resolve, 100)) + + // then - notification should NOT be sent (streaming activity cancelled it) + expect(notificationCalls).toHaveLength(0) + }) + test("should mark session activity on tool.execute.before event", async () => { // given - main session is set const mainSessionID = "main-tool" diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index c9178f0df..e65f358cd 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif import { hasIncompleteTodos } from "./session-todo-status" import { createIdleNotificationScheduler } from "./session-notification-scheduler" import { createSessionNotificationInit } from "./session-notification-init" +import { resolveSessionEventID } from "../shared/event-session-id" interface SessionNotificationConfig { title?: string @@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif const props = event.properties as Record | undefined if (event.type === "session.created") { - const info = props?.info as Record | undefined - const sessionID = info?.id as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) scheduler.markSessionActivity(sessionID) return } @@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif return } - if (event.type === "message.updated") { + if ( + event.type === "message.updated" || + event.type === "message.part.updated" || + event.type === "message.part.delta" + ) { const info = props?.info as Record | undefined const sessionID = getSessionID({ ...props, info }) if (sessionID) scheduler.markSessionActivity(sessionID) @@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) scheduler.deleteSession(sessionID) } } } diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts index 0e7912571..6a1a8e6b9 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -11,6 +11,10 @@ type ClientWithPromptAsync = { } } +function hasPromptAsync(client: Client): client is Client & ClientWithPromptAsync { + return "promptAsync" in client.session && typeof client.session.promptAsync === "function" +} + interface ToolUsePart { type: "tool_use" @@ -111,7 +115,11 @@ export async function recoverToolResultMissing( } try { - await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput) + if (!hasPromptAsync(client)) { + return false + } + + await client.session.promptAsync(promptInput) return true } catch { diff --git a/src/hooks/session-recovery/storage/readers-from-sdk.test.ts b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts index 4b63cad6b..6c7fa74b0 100644 --- a/src/hooks/session-recovery/storage/readers-from-sdk.test.ts +++ b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test" +import { unsafeTestValue } from "../../../../test-support/unsafe-test-value" async function importFreshReaders() { const token = `${Date.now()}-${Math.random()}` const [{ readMessagesFromSDK, readMessages }, { readPartsFromSDK, readParts }] = await Promise.all([ @@ -13,7 +14,7 @@ function createMockClient(handlers: { messages?: (sessionID: string) => unknown[] message?: (sessionID: string, messageID: string) => unknown }) { - return { + return unsafeTestValue({ session: { messages: async (opts: { path: { id: string } }) => { if (handlers.messages) { @@ -28,7 +29,7 @@ function createMockClient(handlers: { throw new Error("not implemented") }, }, - } as unknown + }) } describe("session-recovery storage SDK readers", () => { diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index e33a4f6a7..e4a981218 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -16,6 +16,7 @@ import { import type { BoulderState } from "../../features/boulder-state" import * as sessionState from "../../features/claude-code-session-state" import * as worktreeDetector from "./worktree-detector" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("start-work hook", () => { let testDir: string @@ -738,7 +739,7 @@ You are starting a Sisyphus work session. const promptAsyncMock = spyOn({ promptAsync: async (_request: unknown) => undefined, }, "promptAsync") - const ctx = { + const ctx = unsafeTestValue[0]>({ directory: testDir, client: { session: { @@ -747,7 +748,7 @@ You are starting a Sisyphus work session. messages: async () => ({ data: [] }), }, }, - } as unknown as Parameters[0] + }) const startWorkHook = createStartWorkHook(ctx) const atlasHook = createAtlasHook(ctx) const output = { @@ -784,18 +785,18 @@ You are starting a Sisyphus work session. promptAsync: async (_request: unknown) => undefined, }, "promptAsync") - globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => { + globalThis.setTimeout = unsafeTestValue(((callback: Function, delay?: number, ...args: unknown[]) => { const normalized = typeof delay === "number" ? delay : 0 if (normalized >= 5000) { const id = nextTimerId++ capturedTimers.set(id, { callback: () => callback(...args), cleared: false }) - return id as unknown as ReturnType + return unsafeTestValue>(id) } return originalSetTimeout(callback as Parameters[0], delay) - }) as unknown as typeof setTimeout + })) - globalThis.clearTimeout = ((id?: number | ReturnType) => { + globalThis.clearTimeout = unsafeTestValue(((id?: number | ReturnType) => { if (typeof id === "number" && capturedTimers.has(id)) { capturedTimers.get(id)!.cleared = true capturedTimers.delete(id) @@ -803,11 +804,11 @@ You are starting a Sisyphus work session. } originalClearTimeout(id as Parameters[0]) - }) as unknown as typeof clearTimeout + })) Date.now = () => fakeNow - const ctx = { + const ctx = unsafeTestValue[0]>({ directory: testDir, client: { session: { @@ -816,13 +817,13 @@ You are starting a Sisyphus work session. messages: async () => ({ data: [] }), }, }, - } as unknown as Parameters[0] + }) const startWorkHook = createStartWorkHook(ctx) const atlasHook = createAtlasHook(ctx, { directory: testDir, - backgroundManager: { + backgroundManager: unsafeTestValue[1]>["backgroundManager"]>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], - } as unknown as NonNullable[1]>["backgroundManager"], + }), }) const output = { message: {} as Record, diff --git a/src/hooks/stop-continuation-guard/hook.ts b/src/hooks/stop-continuation-guard/hook.ts index ce3ba7c0b..94c78893e 100644 --- a/src/hooks/stop-continuation-guard/hook.ts +++ b/src/hooks/stop-continuation-guard/hook.ts @@ -5,6 +5,7 @@ import { clearContinuationMarker, setContinuationMarkerSource, } from "../../features/run-continuation-state" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" const HOOK_NAME = "stop-continuation-guard" @@ -86,11 +87,11 @@ export function createStopContinuationGuardHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clear(sessionInfo.id) - clearContinuationMarker(ctx.directory, sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clear(sessionID) + clearContinuationMarker(ctx.directory, sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } } } diff --git a/src/hooks/stop-continuation-guard/index.test.ts b/src/hooks/stop-continuation-guard/index.test.ts index 8fa0a11a7..7ecf0001a 100644 --- a/src/hooks/stop-continuation-guard/index.test.ts +++ b/src/hooks/stop-continuation-guard/index.test.ts @@ -6,6 +6,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" import { readContinuationMarker } from "../../features/run-continuation-state" import { createStopContinuationGuardHook } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type CancelCall = { taskId: string @@ -31,14 +32,14 @@ describe("stop-continuation-guard", () => { }) function createMockPluginInput() { - return { + return unsafeTestValue({ client: { tui: { showToast: async () => ({}), }, }, directory: createTempDir(), - } as unknown as PluginInput + }) } function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask { diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts index 4e795018d..9a09daeaa 100644 --- a/src/hooks/task-reminder/hook.ts +++ b/src/hooks/task-reminder/hook.ts @@ -1,5 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { resolveSessionEventID } from "../../shared/event-session-id" + const TASK_TOOLS = new Set([ "task", "task_create", @@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) { "tool.execute.after": toolExecuteAfter, event: async ({ event }: { event: { type: string; properties?: unknown } }) => { if (event.type !== "session.deleted") return - const props = event.properties as { info?: { id?: string } } | undefined - const sessionId = props?.info?.id + const sessionId = resolveSessionEventID(event.properties) if (!sessionId) return sessionCounters.delete(sessionId) }, diff --git a/src/hooks/task-resume-info/index.test.ts b/src/hooks/task-resume-info/index.test.ts index 30708380b..3ffb01718 100644 --- a/src/hooks/task-resume-info/index.test.ts +++ b/src/hooks/task-resume-info/index.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "bun:test" import { createTaskResumeInfoHook } from "./index" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("createTaskResumeInfoHook", () => { const hook = createTaskResumeInfoHook() @@ -19,7 +20,7 @@ describe("createTaskResumeInfoHook", () => { const input = createInput("task") const output = { title: "delegate_task", - output: undefined as unknown as string, + output: unsafeTestValue(undefined), metadata: {}, } diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index 0bb99ab23..8d2c8a5d9 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -7,6 +7,7 @@ import { applyMemberSessionRouting, buildMemberPromptBody, } from "../../features/team-mode/member-session-routing" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" import { settleAfterSessionIdle } from "../shared/session-idle-settle" @@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise type TeamIdleWakeHintOptions = { idleSettleMs?: number } function getIdleSessionID(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } function buildWakeHint(unreadCount: number): string { diff --git a/src/hooks/team-session-events/team-lead-orphan-handler.ts b/src/hooks/team-session-events/team-lead-orphan-handler.ts index e7b70b3d5..07349ab32 100644 --- a/src/hooks/team-session-events/team-lead-orphan-handler.ts +++ b/src/hooks/team-session-events/team-lead-orphan-handler.ts @@ -3,14 +3,14 @@ import type { BackgroundManager } from "../../features/background-agent/manager" import { lookupTeamSession } from "../../features/team-mode/team-session-registry" import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import type { TmuxSessionManager } from "../../features/tmux-subagent/manager" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } export type HookImpl = (input: HookInput) => Promise function getDeletedSessionID(properties: unknown): string | undefined { - const record = properties as { info?: { id?: string } } | undefined - return record?.info?.id + return resolveSessionEventID(properties) } async function findLeadTeamRunId( diff --git a/src/hooks/team-session-events/team-member-error-handler.ts b/src/hooks/team-session-events/team-member-error-handler.ts index 89dc67601..c7f669c8c 100644 --- a/src/hooks/team-session-events/team-member-error-handler.ts +++ b/src/hooks/team-session-events/team-member-error-handler.ts @@ -1,14 +1,14 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } export type HookImpl = (input: HookInput) => Promise function getErroredSessionID(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl { diff --git a/src/hooks/team-session-events/team-member-status-handler.ts b/src/hooks/team-session-events/team-member-status-handler.ts index 3e31173c7..011b9769c 100644 --- a/src/hooks/team-session-events/team-member-status-handler.ts +++ b/src/hooks/team-session-events/team-member-status-handler.ts @@ -2,6 +2,7 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import type { RuntimeStateMember } from "../../features/team-mode/types" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } @@ -13,13 +14,11 @@ const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet = new Set(["run const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet = new Set(["running", "idle", "pending"]) function getSessionIDFromIdleEvent(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } function getSessionIDFromDeletedEvent(properties: unknown): string | undefined { - const record = properties as { info?: { id?: string } } | undefined - return record?.info?.id + return resolveSessionEventID(properties) } async function transitionMemberStatus( diff --git a/src/hooks/think-mode/hook.ts b/src/hooks/think-mode/hook.ts index 8f2442382..732096b28 100644 --- a/src/hooks/think-mode/hook.ts +++ b/src/hooks/think-mode/hook.ts @@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector" import { isAlreadyHighVariant } from "./switcher" import type { ThinkModeState } from "./types" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" const thinkModeState = new Map() @@ -66,9 +67,9 @@ export function createThinkModeHook() { event: async ({ event }: { event: { type: string; properties?: unknown } }) => { if (event.type === "session.deleted") { - const props = event.properties as { info?: { id?: string } } | undefined - if (props?.info?.id) { - thinkModeState.delete(props.info.id) + const sessionID = resolveSessionEventID(event.properties) + if (sessionID) { + thinkModeState.delete(sessionID) } } }, diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 27096056f..0cc85fba8 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -5,6 +5,7 @@ import { clearContinuationMarker, } from "../../features/run-continuation-state" import { log } from "../../shared/logger" +import { resolveSessionEventID } from "../../shared/event-session-id" import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" import { armCompactionGuard } from "./compaction-guard" @@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: { const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const error = extractSessionErrorInfo(props?.error) @@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return sessionStateStore.startPruneInterval() @@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { const state = sessionStateStore.getState(sessionID) const compactionEpoch = armCompactionGuard(state, Date.now()) @@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clearContinuationMarker(ctx.directory, sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clearContinuationMarker(ctx.directory, sessionID) } } diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index a88da8773..dc21fa8f1 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -1,4 +1,5 @@ import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" import type { SessionStateStore } from "./session-state" @@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: { if (eventType === "message.updated") { const info = properties?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) const role = info?.role as string | undefined if (!sessionID) return @@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "message.part.updated") { - const sessionID = typeof properties?.sessionID === "string" - ? properties.sessionID - : undefined - const legacyInfo = properties?.info as Record | undefined - const legacySessionID = legacyInfo?.sessionID as string | undefined - const targetSessionID = sessionID ?? legacySessionID + const targetSessionID = resolveMessageEventSessionID(properties) if (targetSessionID) { const state = sessionStateStore.getExistingState(targetSessionID) @@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "message.part.delta") { - const sessionID = properties?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) if (state) { @@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { - const sessionID = properties?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) if (state) { @@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: { } if (eventType === "session.deleted") { - const sessionInfo = properties?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionStateStore.cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + const sessionID = resolveSessionEventID(properties) + if (sessionID) { + sessionStateStore.cleanup(sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } return } 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 03b56ba4e..bb1a56d39 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -12,6 +12,7 @@ import { } from "./constants" type TimerCallback = (...args: any[]) => void +type FakeTimerID = number & ReturnType & ReturnType interface FakeTimers { advanceBy: (ms: number, advanceClock?: boolean) => Promise @@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers { callback, args, }) - return id + return id as FakeTimerID } const clear = (id: number | undefined) => { @@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers { if (normalized >= REAL_MAX_DELAY_MS) { return original.setTimeout(callback, delay, ...args) } - return schedule(callback, normalized, null, args) as unknown as ReturnType + return schedule(callback, normalized, null, args) }) as typeof setTimeout globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => { @@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers { if (interval >= REAL_MAX_DELAY_MS) { return original.setInterval(callback, delay, ...args) } - return schedule(callback, interval, interval, args) as unknown as ReturnType + return schedule(callback, interval, interval, args) }) as typeof setInterval globalThis.clearTimeout = ((id?: Parameters[0]) => { @@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => { } } + type MockPluginInput = Parameters[0] + let mockMessages: MockMessage[] = [] function createMockPluginInput() { @@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => { }, }, directory: "/tmp/test", - } as any + } as MockPluginInput } function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager { @@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => { getTasksByParentSession: () => runningTasks ? [{ status: "running" }] : [], - } as any + } as BackgroundManager } beforeEach(() => { @@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls[0].text).toContain("TODO CONTINUATION") }, { timeout: 15000 }) + test("should inject continuation when idle event carries session id in info", async () => { + fakeTimers.restore() + // given - OpenCode session events can nest the session id under info + const sessionID = "main-info-idle" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle with the nested event shape + await hook.handler({ + event: { type: "session.idle", properties: { info: { id: sessionID } } }, + }) + + // then - continuation is still injected for that session + await wait(2500) + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].sessionID).toBe(sessionID) + expect(promptCalls[0].text).toContain("TODO CONTINUATION") + }, { timeout: 15000 }) + test("should not inject when all todos are complete", async () => { // given - session with all todos complete const sessionID = "main-456" @@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should cancel countdown on assistant activity when message.part.updated only has part session id", async () => { + // given - session starting countdown + const sessionID = "main-assistant-part-only" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // when - legacy part-only sync payload reports assistant output + await fakeTimers.advanceBy(500) + await hook.handler({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID, + type: "text", + text: "working", + }, + time: Date.now(), + }, + }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected (cancelled) + expect(promptCalls).toHaveLength(0) + }) + test("should cancel countdown on assistant activity with message.part.delta payload", async () => { // given - session starting countdown const sessionID = "main-assistant-delta" @@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, {}) @@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, {}) @@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { skipAgents: [], @@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => { const mockInput = createMockPluginInput() mockInput.client.session.promptAsync = async () => { const error = new Error("prompt is too long: 150000 tokens > 100000 maximum") - ;(error as any).name = "ContextLengthError" + error.name = "ContextLengthError" throw error } diff --git a/src/hooks/todo-description-override/description.ts b/src/hooks/todo-description-override/description.ts index 98129a8d2..2046889c7 100644 --- a/src/hooks/todo-description-override/description.ts +++ b/src/hooks/todo-description-override/description.ts @@ -1,5 +1,15 @@ export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work. +## OpenCode Schema Contract + +The upstream OpenCode \`todowrite\` schema expects each todo item to include: + +- \`content\`: string +- \`status\`: string, one of \`pending\`, \`in_progress\`, \`completed\`, \`cancelled\` +- \`priority\`: string, one of \`high\`, \`medium\`, \`low\` + +\`priority\` is a string field. Never send numeric priorities such as \`0\`, \`1\`, \`2\`, or labels such as \`P0\`, \`P1\`, \`P2\`. + ## Todo Format (MANDATORY) Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT. diff --git a/src/hooks/todo-description-override/index.test.ts b/src/hooks/todo-description-override/index.test.ts index 374b13a98..0976a229d 100644 --- a/src/hooks/todo-description-override/index.test.ts +++ b/src/hooks/todo-description-override/index.test.ts @@ -37,4 +37,14 @@ describe("createTodoDescriptionOverrideHook", () => { }) }) }) + + describe("#given todowrite description is overridden", () => { + describe("#when the model reads schema guidance", () => { + it("#then should require string priorities matching OpenCode schema", () => { + expect(TODOWRITE_DESCRIPTION).toContain("`priority`: string") + expect(TODOWRITE_DESCRIPTION).toContain("`high`, `medium`, `low`") + expect(TODOWRITE_DESCRIPTION).toContain("Never send numeric priorities") + }) + }) + }) }) diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index c5168759d..6f43a7840 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent" import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { isAbortError } from "../../shared/is-abort-error" import { buildReminder, @@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID || !isAbortError(props?.error)) return cancelledSessions.add(sessionID) @@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "session.stop") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return cancelledSessions.add(sessionID) @@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID || (role !== "user" && role !== "assistant")) return @@ -158,7 +159,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (!sessionID) return cancelledSessions.delete(sessionID) @@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (!sessionInfo?.id) return + const sessionID = resolveSessionEventID(props) + if (!sessionID) return - cancelledSessions.delete(sessionInfo.id) + cancelledSessions.delete(sessionID) return } if (event.type !== "session.idle") return - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const mainSessionID = getMainSessionID() diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index 19b72eb73..96da6fa02 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs" import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler" +import { resolveSessionEventID } from "../../shared/event-session-id" export type GuardArgs = { filePath?: string @@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri return } - const props = event.properties as { info?: { id?: string } } | undefined - const sessionID = props?.info?.id + const sessionID = resolveSessionEventID(event.properties) if (!sessionID) { return } diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts index e93f8694d..83e0f748a 100644 --- a/src/index.telemetry.test.ts +++ b/src/index.telemetry.test.ts @@ -38,11 +38,15 @@ function installModuleMocks(): void { detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })), getSkillPluginConflictWarning: mock(() => ""), })) - mock.module("./shared", () => ({ - injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + mock.module("./shared/logger", () => ({ log: mock(() => {}), + })) + mock.module("./shared/log-legacy-plugin-startup-warning", () => ({ logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, })) + mock.module("./shared/opencode-server-auth", () => ({ + injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + })) mock.module("./plugin-config", () => ({ loadPluginConfig: mockLoadPluginConfig, })) diff --git a/src/index.test.ts b/src/index.test.ts index 0b8499983..b1f5e2a73 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -52,12 +52,18 @@ function installIndexModuleMocks(): void { getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning, })) - mock.module("./shared", () => ({ - injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + mock.module("./shared/logger", () => ({ log: mock(() => {}), + })) + + mock.module("./shared/log-legacy-plugin-startup-warning", () => ({ logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, })) + mock.module("./shared/opencode-server-auth", () => ({ + injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + })) + mock.module("./plugin-config", () => ({ loadPluginConfig: mockLoadPluginConfig, })) diff --git a/src/index.ts b/src/index.ts index 808141b32..7d229a134 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,9 @@ import { import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" -import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared" +import { log } from "./shared/logger" +import { logLegacyPluginStartupWarning } from "./shared/log-legacy-plugin-startup-warning" +import { injectServerAuthIntoClient } from "./shared/opencode-server-auth" import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" diff --git a/src/openclaw/__tests__/config.test.ts b/src/openclaw/__tests__/config.test.ts index 62972f45a..60d3effaf 100644 --- a/src/openclaw/__tests__/config.test.ts +++ b/src/openclaw/__tests__/config.test.ts @@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test" import { resolveGateway, validateGatewayUrl, normalizeReplyListenerConfig } from "../config" import type { OpenClawConfig } from "../types" import { OpenClawConfigSchema } from "../../config/schema/openclaw" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("OpenClaw Config", () => { test("resolveGateway resolves HTTP gateway", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: { discord: { @@ -20,7 +21,7 @@ describe("OpenClaw Config", () => { instruction: "Started session {{sessionId}}", }, }, - } as any + }) const resolved = resolveGateway(config, "session-start") expect(resolved).not.toBeNull() @@ -30,31 +31,31 @@ describe("OpenClaw Config", () => { }) test("resolveGateway returns null for disabled config", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = unsafeTestValue({ enabled: false, gateways: {}, hooks: {}, - } as any + }) expect(resolveGateway(config, "session-start")).toBeNull() }) test("resolveGateway returns null for unknown hook", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: {}, hooks: {}, - } as any + }) expect(resolveGateway(config, "unknown")).toBeNull() }) test("resolveGateway returns null for disabled hook", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: { g: { type: "http", url: "https://example.com" } }, hooks: { event: { enabled: false, gateway: "g", instruction: "i" }, }, - } as any + }) expect(resolveGateway(config, "event")).toBeNull() }) diff --git a/src/openclaw/__tests__/reply-listener-discord.test.ts b/src/openclaw/__tests__/reply-listener-discord.test.ts index 8fcc77c03..4d01220ca 100644 --- a/src/openclaw/__tests__/reply-listener-discord.test.ts +++ b/src/openclaw/__tests__/reply-listener-discord.test.ts @@ -8,6 +8,7 @@ import * as injectionModule from "../reply-listener-injection" import * as sessionRegistryModule from "../session-registry" import type { ReplyListenerDaemonState } from "../reply-listener-state" import type { OpenClawConfig } from "../types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const originalFetch = globalThis.fetch @@ -75,7 +76,7 @@ describe("pollDiscordReplies", () => { status: 401, }), )) - globalThis.fetch = fetchMock as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(fetchMock) const state = createState() @@ -109,7 +110,7 @@ describe("pollDiscordReplies", () => { ), ) .mockResolvedValueOnce(new Response(null, { status: 204 })) - globalThis.fetch = fetchMock as unknown as typeof fetch + globalThis.fetch = unsafeTestValue(fetchMock) const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({ sessionId: "ses-1", tmuxSession: "session-1", diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index d2b0b9ea9..bfc189341 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -22,6 +22,7 @@ import * as modelResolver from "../shared/model-resolver" import * as configErrors from "../shared/config-errors" import * as agentPriorityOrder from "./agent-priority-order" import * as prometheusAgentConfigBuilder from "./prometheus-agent-config-builder" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" let createConfigHandler: (typeof import("./config-handler"))["createConfigHandler"] @@ -46,36 +47,36 @@ beforeEach(async () => { mock.restore() configErrors.clearConfigLoadErrors() - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + spyOn(agents, unsafeTestValue("createBuiltinAgents")).mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, oracle: { name: "oracle", prompt: "test", mode: "subagent" }, }) - spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({}) - spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({}) - spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({}) - spyOn(commandLoader, "loadOpencodeProjectCommands" as any).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadUserCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadProjectCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadOpencodeGlobalCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadOpencodeProjectCommands")).mockResolvedValue({}) - spyOn(builtinCommands, "loadBuiltinCommands" as any).mockReturnValue({}) + spyOn(builtinCommands, unsafeTestValue("loadBuiltinCommands")).mockReturnValue({}) - spyOn(skillLoader, "loadUserSkills" as any).mockResolvedValue({}) - spyOn(skillLoader, "loadProjectSkills" as any).mockResolvedValue({}) - spyOn(skillLoader, "loadOpencodeGlobalSkills" as any).mockResolvedValue({}) - spyOn(skillLoader, "loadOpencodeProjectSkills" as any).mockResolvedValue({}) - spyOn(skillLoader, "discoverUserClaudeSkills" as any).mockResolvedValue([]) - spyOn(skillLoader, "discoverProjectClaudeSkills" as any).mockResolvedValue([]) - spyOn(skillLoader, "discoverOpencodeGlobalSkills" as any).mockResolvedValue([]) - spyOn(skillLoader, "discoverOpencodeProjectSkills" as any).mockResolvedValue([]) + spyOn(skillLoader, unsafeTestValue("loadUserSkills")).mockResolvedValue({}) + spyOn(skillLoader, unsafeTestValue("loadProjectSkills")).mockResolvedValue({}) + spyOn(skillLoader, unsafeTestValue("loadOpencodeGlobalSkills")).mockResolvedValue({}) + spyOn(skillLoader, unsafeTestValue("loadOpencodeProjectSkills")).mockResolvedValue({}) + spyOn(skillLoader, unsafeTestValue("discoverUserClaudeSkills")).mockResolvedValue([]) + spyOn(skillLoader, unsafeTestValue("discoverProjectClaudeSkills")).mockResolvedValue([]) + spyOn(skillLoader, unsafeTestValue("discoverOpencodeGlobalSkills")).mockResolvedValue([]) + spyOn(skillLoader, unsafeTestValue("discoverOpencodeProjectSkills")).mockResolvedValue([]) - spyOn(agentLoader, "loadUserAgents" as any).mockReturnValue({}) - spyOn(agentLoader, "loadProjectAgents" as any).mockReturnValue({}) - spyOn(agentLoader, "loadOpencodeGlobalAgents" as any).mockReturnValue({}) - spyOn(agentLoader, "loadOpencodeProjectAgents" as any).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadUserAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadProjectAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadOpencodeGlobalAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadOpencodeProjectAgents")).mockReturnValue({}) - spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} }) + spyOn(mcpLoader, unsafeTestValue("loadMcpConfigs")).mockResolvedValue({ servers: {}, loadedServers: [] }) setAdditionalAllowedMcpEnvVarsSpy = spyOn(mcpLoader, "setAdditionalAllowedMcpEnvVars").mockImplementation(() => {}) - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({ + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockResolvedValue({ commands: {}, skills: {}, agents: {}, @@ -85,54 +86,57 @@ beforeEach(async () => { errors: [], }) - spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({}) + spyOn(mcpModule, unsafeTestValue("createBuiltinMcps")).mockReturnValue({}) - spyOn(shared, "log" as any).mockImplementation(() => {}) - spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-7"])) - spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null) + spyOn(shared, unsafeTestValue("log")).mockImplementation(() => {}) + spyOn(shared, unsafeTestValue("fetchAvailableModels")).mockResolvedValue(new Set(["anthropic/claude-opus-4-7"])) + spyOn(shared, unsafeTestValue("readConnectedProvidersCache")).mockReturnValue(null) - spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({ - global: "/tmp/.config/opencode", - project: "/tmp/.opencode", + spyOn(configDir, unsafeTestValue("getOpenCodeConfigPaths")).mockReturnValue({ + configDir: "/tmp/.config/opencode", + configJson: "/tmp/.config/opencode/opencode.json", + configJsonc: "/tmp/.config/opencode/opencode.jsonc", + packageJson: "/tmp/.config/opencode/package.json", + omoConfig: "/tmp/.config/opencode/oh-my-opencode.jsonc", }) - spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record) => config) + spyOn(permissionCompat, unsafeTestValue("migrateAgentConfig")).mockImplementation((config: Record) => config) - spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-7" }) + spyOn(modelResolver, unsafeTestValue("resolveModelWithFallback")).mockReturnValue({ model: "anthropic/claude-opus-4-7", source: "provider-fallback" }) ;({ createConfigHandler } = await importFreshConfigHandlerModule()) }) afterEach(() => { - (agents.createBuiltinAgents as any)?.mockRestore?.() - ;(sisyphusJunior.createSisyphusJuniorAgentWithOverrides as any)?.mockRestore?.() - ;(commandLoader.loadUserCommands as any)?.mockRestore?.() - ;(commandLoader.loadProjectCommands as any)?.mockRestore?.() - ;(commandLoader.loadOpencodeGlobalCommands as any)?.mockRestore?.() - ;(commandLoader.loadOpencodeProjectCommands as any)?.mockRestore?.() - ;(builtinCommands.loadBuiltinCommands as any)?.mockRestore?.() - ;(skillLoader.loadUserSkills as any)?.mockRestore?.() - ;(skillLoader.loadProjectSkills as any)?.mockRestore?.() - ;(skillLoader.loadOpencodeGlobalSkills as any)?.mockRestore?.() - ;(skillLoader.loadOpencodeProjectSkills as any)?.mockRestore?.() - ;(skillLoader.discoverUserClaudeSkills as any)?.mockRestore?.() - ;(skillLoader.discoverProjectClaudeSkills as any)?.mockRestore?.() - ;(skillLoader.discoverOpencodeGlobalSkills as any)?.mockRestore?.() - ;(skillLoader.discoverOpencodeProjectSkills as any)?.mockRestore?.() - ;(agentLoader.loadUserAgents as any)?.mockRestore?.() - ;(agentLoader.loadProjectAgents as any)?.mockRestore?.() - ;(agentLoader.loadOpencodeGlobalAgents as any)?.mockRestore?.() - ;(agentLoader.loadOpencodeProjectAgents as any)?.mockRestore?.() - ;(mcpLoader.loadMcpConfigs as any)?.mockRestore?.() + (unsafeTestValue(agents.createBuiltinAgents))?.mockRestore?.() + ;(unsafeTestValue(sisyphusJunior.createSisyphusJuniorAgentWithOverrides))?.mockRestore?.() + ;(unsafeTestValue(commandLoader.loadUserCommands))?.mockRestore?.() + ;(unsafeTestValue(commandLoader.loadProjectCommands))?.mockRestore?.() + ;(unsafeTestValue(commandLoader.loadOpencodeGlobalCommands))?.mockRestore?.() + ;(unsafeTestValue(commandLoader.loadOpencodeProjectCommands))?.mockRestore?.() + ;(unsafeTestValue(builtinCommands.loadBuiltinCommands))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.loadUserSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.loadProjectSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.loadOpencodeGlobalSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.loadOpencodeProjectSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.discoverUserClaudeSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.discoverProjectClaudeSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.discoverOpencodeGlobalSkills))?.mockRestore?.() + ;(unsafeTestValue(skillLoader.discoverOpencodeProjectSkills))?.mockRestore?.() + ;(unsafeTestValue(agentLoader.loadUserAgents))?.mockRestore?.() + ;(unsafeTestValue(agentLoader.loadProjectAgents))?.mockRestore?.() + ;(unsafeTestValue(agentLoader.loadOpencodeGlobalAgents))?.mockRestore?.() + ;(unsafeTestValue(agentLoader.loadOpencodeProjectAgents))?.mockRestore?.() + ;(unsafeTestValue(mcpLoader.loadMcpConfigs))?.mockRestore?.() setAdditionalAllowedMcpEnvVarsSpy?.mockRestore() - ;(pluginLoader.loadAllPluginComponents as any)?.mockRestore?.() - ;(mcpModule.createBuiltinMcps as any)?.mockRestore?.() - ;(shared.log as any)?.mockRestore?.() - ;(shared.fetchAvailableModels as any)?.mockRestore?.() - ;(shared.readConnectedProvidersCache as any)?.mockRestore?.() - ;(configDir.getOpenCodeConfigPaths as any)?.mockRestore?.() - ;(permissionCompat.migrateAgentConfig as any)?.mockRestore?.() - ;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.() - ;(agentPriorityOrder.reorderAgentsByPriority as any)?.mockRestore?.() + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents))?.mockRestore?.() + ;(unsafeTestValue(mcpModule.createBuiltinMcps))?.mockRestore?.() + ;(unsafeTestValue(shared.log))?.mockRestore?.() + ;(unsafeTestValue(shared.fetchAvailableModels))?.mockRestore?.() + ;(unsafeTestValue(shared.readConnectedProvidersCache))?.mockRestore?.() + ;(unsafeTestValue(configDir.getOpenCodeConfigPaths))?.mockRestore?.() + ;(unsafeTestValue(permissionCompat.migrateAgentConfig))?.mockRestore?.() + ;(unsafeTestValue(modelResolver.resolveModelWithFallback))?.mockRestore?.() + ;(unsafeTestValue(agentPriorityOrder.reorderAgentsByPriority))?.mockRestore?.() configErrors.clearConfigLoadErrors() mock.restore() }) @@ -230,10 +234,10 @@ describe("MCP env allowlist initialization", () => { describe("Plan agent demote behavior", () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -275,17 +279,17 @@ describe("Plan agent demote behavior", () => { test("assembles core agents first before priority reorder runs", async () => { // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, oracle: { name: "oracle", prompt: "test", mode: "subagent" }, atlas: { name: "atlas", prompt: "test", mode: "primary" }, }) - const reorderSpy = spyOn(agentPriorityOrder, "reorderAgentsByPriority") as any + const reorderSpy = unsafeTestValue(spyOn(agentPriorityOrder, "reorderAgentsByPriority")) const pluginConfig = createPluginConfig({ sisyphus_agent: { planner_enabled: true, @@ -321,9 +325,9 @@ describe("Plan agent demote behavior", () => { test("backfills runtime core agent names when builtin configs omit name", async () => { // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { prompt: "test", mode: "primary" }, hephaestus: { prompt: "test", mode: "primary" }, @@ -485,9 +489,9 @@ describe("Plan agent demote behavior", () => { describe("Agent permission defaults", () => { test("hephaestus should allow task", async () => { // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -1054,7 +1058,7 @@ describe("Plan agent model inheritance from prometheus", () => { test("plan agent inherits temperature, reasoningEffort, and other model settings from prometheus", async () => { //#given - prometheus configured with category that has temperature and reasoningEffort - spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + spyOn(shared, unsafeTestValue("resolveModelPipeline")).mockReturnValue({ model: "openai/gpt-5.4", provenance: "override", variant: "high", @@ -1109,7 +1113,7 @@ describe("Plan agent model inheritance from prometheus", () => { test("plan agent user override takes priority over prometheus inherited settings", async () => { //#given - prometheus resolves to opus, but user has plan override for gpt-5.4 - spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + spyOn(shared, unsafeTestValue("resolveModelPipeline")).mockReturnValue({ model: "anthropic/claude-opus-4-7", provenance: "provider-fallback", variant: "max", @@ -1152,7 +1156,7 @@ describe("Plan agent model inheritance from prometheus", () => { test("plan agent does NOT inherit prompt, description, or color from prometheus", async () => { //#given - spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + spyOn(shared, unsafeTestValue("resolveModelPipeline")).mockReturnValue({ model: "anthropic/claude-opus-4-7", provenance: "provider-fallback", variant: "max", @@ -1229,8 +1233,10 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", ( describe("config-handler plugin loading error boundary (#1559)", () => { test("returns empty defaults when loadAllPluginComponents throws", async () => { //#given - ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash")) + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockImplementation(async () => { + throw new Error("crash") + }) const pluginConfig = createPluginConfig({}) const config: Record = { model: "anthropic/claude-opus-4-7", @@ -1255,8 +1261,8 @@ describe("config-handler plugin loading error boundary (#1559)", () => { test("returns empty defaults when loadAllPluginComponents times out", async () => { //#given - ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockImplementation( + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockImplementation( () => new Promise(() => {}) ) const pluginConfig = createPluginConfig({ @@ -1285,8 +1291,10 @@ describe("config-handler plugin loading error boundary (#1559)", () => { test("records a config load error when loadAllPluginComponents fails", async () => { //#given - ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash")) + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockImplementation(async () => { + throw new Error("crash") + }) const pluginConfig = createPluginConfig({}) const config: Record = { model: "anthropic/claude-opus-4-7", @@ -1314,14 +1322,14 @@ describe("config-handler plugin loading error boundary (#1559)", () => { test("passes through plugin data on successful load (identity test)", async () => { //#given - ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({ - commands: { "test-cmd": { description: "test", template: "test" } }, + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockResolvedValue({ + commands: { "test-cmd": { name: "test-cmd", description: "test", template: "test" } }, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], - plugins: [{ name: "test-plugin", version: "1.0.0" }], + plugins: [{ name: "test-plugin", version: "1.0.0", scope: "project", installPath: "/tmp/test-plugin", pluginKey: "test-plugin" }], errors: [], }) const pluginConfig = createPluginConfig({}) @@ -1351,16 +1359,16 @@ 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 { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, atlas: { name: "atlas", prompt: "test", mode: "primary" }, }) - ;(builtinCommands.loadBuiltinCommands as unknown as { + ;(unsafeTestValue<{ mockReturnValue: (value: Record) => void - }).mockReturnValue({ + }>(builtinCommands.loadBuiltinCommands)).mockReturnValue({ "start-work": { name: "start-work", description: "(builtin) Start work", @@ -1404,9 +1412,9 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -1445,10 +1453,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is disabled", async () => { //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -1487,10 +1495,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is undefined", async () => { //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, }) @@ -1526,10 +1534,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { describe("disable_omo_env pass-through", () => { test("passes disable_omo_env=true to createBuiltinAgents", async () => { //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "without-env", mode: "primary" }, }) @@ -1563,10 +1571,10 @@ describe("disable_omo_env pass-through", () => { test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => { //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" }, }) @@ -1600,14 +1608,14 @@ describe("disable_omo_env pass-through", () => { describe("Agent merge priority — project-local overrides global", () => { test("project-local Claude agent overrides global Claude agent with same name", async () => { // #given — same agent name in both global (user) and project scopes - ;(agentLoader.loadUserAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadUserAgents)).mockReturnValue({ "my-custom-agent": { description: "(user) global version", mode: "subagent", prompt: "I am the global agent", }, }) - ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(project) project version", mode: "subagent", @@ -1615,7 +1623,7 @@ describe("Agent merge priority — project-local overrides global", () => { }, }) - const pluginConfig: OhMyOpenCodeConfig = {} + const pluginConfig = createPluginConfig() const config: Record = { model: "anthropic/claude-opus-4-7", agent: {}, @@ -1640,14 +1648,14 @@ describe("Agent merge priority — project-local overrides global", () => { test("opencode project agent overrides opencode global agent with same name", async () => { // #given — same agent name in opencode global vs opencode project - ;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(agentLoader.loadOpencodeProjectAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadOpencodeProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode-project) project version", mode: "subagent", @@ -1655,7 +1663,7 @@ describe("Agent merge priority — project-local overrides global", () => { }, }) - const pluginConfig: OhMyOpenCodeConfig = {} + const pluginConfig = createPluginConfig() const config: Record = { model: "anthropic/claude-opus-4-7", agent: {}, @@ -1680,14 +1688,14 @@ describe("Agent merge priority — project-local overrides global", () => { test("project Claude agent overrides opencode global agent with same name", async () => { // #given — project-scope Claude agent vs global-scope opencode agent - ;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(project) project version", mode: "subagent", @@ -1695,7 +1703,7 @@ describe("Agent merge priority — project-local overrides global", () => { }, }) - const pluginConfig: OhMyOpenCodeConfig = {} + const pluginConfig = createPluginConfig() const config: Record = { model: "anthropic/claude-opus-4-7", agent: {}, @@ -1720,7 +1728,7 @@ describe("Agent merge priority — project-local overrides global", () => { test("plugin agents have lowest priority — overridden by all other sources", async () => { // #given — same agent in plugin, global, and project scopes - ;(pluginLoader.loadAllPluginComponents as any).mockResolvedValue({ + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockResolvedValue({ commands: {}, skills: {}, agents: { @@ -1735,7 +1743,7 @@ describe("Agent merge priority — project-local overrides global", () => { plugins: [], errors: [], }) - ;(agentLoader.loadUserAgents as any).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadUserAgents)).mockReturnValue({ "my-custom-agent": { description: "(user) global version", mode: "subagent", @@ -1743,7 +1751,7 @@ describe("Agent merge priority — project-local overrides global", () => { }, }) - const pluginConfig: OhMyOpenCodeConfig = {} + const pluginConfig = createPluginConfig() const config: Record = { model: "anthropic/claude-opus-4-7", agent: {}, diff --git a/src/plugin-handlers/mcp-config-handler.test.ts b/src/plugin-handlers/mcp-config-handler.test.ts index f9fc6472f..217ca303e 100644 --- a/src/plugin-handlers/mcp-config-handler.test.ts +++ b/src/plugin-handlers/mcp-config-handler.test.ts @@ -6,22 +6,23 @@ import type { OhMyOpenCodeConfig } from "../config" import * as mcpLoader from "../features/claude-code-mcp-loader" import * as mcpModule from "../mcp" import * as shared from "../shared" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" let loadMcpConfigsSpy: ReturnType let createBuiltinMcpsSpy: ReturnType beforeEach(() => { - loadMcpConfigsSpy = spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ + loadMcpConfigsSpy = spyOn(mcpLoader, unsafeTestValue("loadMcpConfigs")).mockResolvedValue({ servers: {}, }) - createBuiltinMcpsSpy = spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({}) - spyOn(shared, "log" as any).mockImplementation(() => {}) + createBuiltinMcpsSpy = spyOn(mcpModule, unsafeTestValue("createBuiltinMcps")).mockReturnValue({}) + spyOn(shared, unsafeTestValue("log")).mockImplementation(() => {}) }) afterEach(() => { loadMcpConfigsSpy.mockRestore() createBuiltinMcpsSpy.mockRestore() - ;(shared.log as any)?.mockRestore?.() + ;(unsafeTestValue(shared.log))?.mockRestore?.() }) function createPluginConfig(overrides: Partial = {}): OhMyOpenCodeConfig { @@ -82,7 +83,7 @@ describe("applyMcpConfig", () => { }) const config: Record = { mcp: {} } - const pluginConfig = createPluginConfig({ disabled_mcps: ["playwright"] as any }) + const pluginConfig = createPluginConfig({ disabled_mcps: unsafeTestValue(["playwright"]) }) //#when const { applyMcpConfig } = await import("./mcp-config-handler") @@ -107,7 +108,7 @@ describe("applyMcpConfig", () => { test("passes disabled_mcps to loadMcpConfigs", async () => { //#given const config: Record = { mcp: {} } - const pluginConfig = createPluginConfig({ disabled_mcps: ["firecrawl", "exa"] as any }) + const pluginConfig = createPluginConfig({ disabled_mcps: unsafeTestValue(["firecrawl", "exa"]) }) //#when const { applyMcpConfig } = await import("./mcp-config-handler") @@ -145,7 +146,7 @@ describe("applyMcpConfig", () => { test("deletes plugin MCPs that are in disabled_mcps", async () => { //#given const config: Record = { mcp: {} } - const pluginConfig = createPluginConfig({ disabled_mcps: ["plugin:custom"] as any }) + const pluginConfig = createPluginConfig({ disabled_mcps: unsafeTestValue(["plugin:custom"]) }) //#when const { applyMcpConfig } = await import("./mcp-config-handler") diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index ab6725bec..1957700f8 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -13,6 +13,7 @@ import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, import { getAgentListDisplayName } from "../shared/agent-display-names" import { getOmoOpenCodeCacheDir, getOpenCodeCacheDir } from "../shared/data-path" import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } @@ -56,13 +57,13 @@ function createMockHandlerArgs(overrides?: { }) { const appliedSessions: string[] = [] return { - ctx: { client: { tui: { showToast: async () => {} } } } as any, - pluginConfig: (overrides?.pluginConfig ?? {}) as any, + ctx: unsafeTestValue({ client: { tui: { showToast: async () => {} } } }), + pluginConfig: unsafeTestValue((overrides?.pluginConfig ?? {})), firstMessageVariantGate: { shouldOverride: () => overrides?.shouldOverride ?? false, markApplied: (sessionID: string) => { appliedSessions.push(sessionID) }, }, - hooks: { + hooks: unsafeTestValue({ stopContinuationGuard: null, backgroundNotificationHook: null, keywordDetector: null, @@ -70,7 +71,7 @@ function createMockHandlerArgs(overrides?: { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), _appliedSessions: appliedSessions, } } diff --git a/src/plugin/event.model-fallback-2941.test.ts b/src/plugin/event.model-fallback-2941.test.ts index 2b97d2cb7..143c5e09e 100644 --- a/src/plugin/event.model-fallback-2941.test.ts +++ b/src/plugin/event.model-fallback-2941.test.ts @@ -6,6 +6,7 @@ import { createChatMessageHandler } from "./chat-message" import { _resetForTesting, setSessionAgent } from "../features/claude-code-session-state" import { clearPendingModelFallback, createModelFallbackHook, setSessionFallbackChain } from "../hooks/model-fallback/hook" import * as connectedProvidersCache from "../shared/connected-providers-cache" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" type EventInput = { event: { type: string; properties?: unknown } } type EventHandlerArgs = Parameters[0] @@ -13,27 +14,27 @@ type EventHandlerInput = Parameters>[0] type ChatMessageHandlerArgs = Parameters[0] function asEventHandlerInput(input: EventInput): EventHandlerInput { - return input as unknown as EventHandlerInput + return unsafeTestValue(input) } function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] { - return ctx as unknown as EventHandlerArgs["ctx"] + return unsafeTestValue(ctx) } function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] { - return config as unknown as EventHandlerArgs["pluginConfig"] + return unsafeTestValue(config) } function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] { - return ctx as unknown as ChatMessageHandlerArgs["ctx"] + return unsafeTestValue(ctx) } function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] { - return config as unknown as ChatMessageHandlerArgs["pluginConfig"] + return unsafeTestValue(config) } function createEventHandlerManagers(): EventHandlerArgs["managers"] { - return { + return unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -41,17 +42,17 @@ function createEventHandlerManagers(): EventHandlerArgs["managers"] { skillMcpManager: { disconnectSession: async () => {}, }, - } as unknown as EventHandlerArgs["managers"] + }) } function createEventHandlerHooks(modelFallback: ReturnType): EventHandlerArgs["hooks"] { - return { + return unsafeTestValue({ modelFallback, - } as unknown as EventHandlerArgs["hooks"] + }) } function createChatMessageHandlerHooks(modelFallback: ReturnType): ChatMessageHandlerArgs["hooks"] { - return { + return unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -59,7 +60,7 @@ function createChatMessageHandlerHooks(modelFallback: ReturnType void } | undefined diff --git a/src/plugin/event.model-fallback-pin-agent.test.ts b/src/plugin/event.model-fallback-pin-agent.test.ts index db29bcb0e..b9adbdfee 100644 --- a/src/plugin/event.model-fallback-pin-agent.test.ts +++ b/src/plugin/event.model-fallback-pin-agent.test.ts @@ -5,6 +5,7 @@ import { createEventHandler } from "./event" import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook" import * as connectedProvidersCache from "../shared/connected-providers-cache" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined @@ -50,16 +51,16 @@ describe("createEventHandler - model-fallback auto-continuation pins agent/model } const handler = createEventHandler({ - ctx: { + ctx: unsafeTestValue({ directory: "/tmp", client: { session: sessionClient }, - } as any, - pluginConfig: (args?.pluginConfig ?? {}) as any, + }), + pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -67,8 +68,8 @@ describe("createEventHandler - model-fallback auto-continuation pins agent/model skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: args?.hooks ?? ({} as any), + }), + hooks: args?.hooks ?? (unsafeTestValue({})), }) return { handler, promptAsyncBodies, promptBodies } diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 3ff82ae20..654bffbcc 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -6,6 +6,7 @@ import { createChatMessageHandler } from "./chat-message" import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook" import * as connectedProvidersCache from "../shared/connected-providers-cache" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined @@ -22,7 +23,7 @@ describe("createEventHandler - model fallback", () => { const promptCalls: string[] = [] const handler = createEventHandler({ - ctx: { + ctx: unsafeTestValue({ directory: "/tmp", client: { session: { @@ -36,13 +37,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: (args?.pluginConfig ?? {}) as any, + }), + pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -50,8 +51,8 @@ describe("createEventHandler - model fallback", () => { skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: args?.hooks ?? ({} as any), + }), + hooks: args?.hooks ?? (unsafeTestValue({})), }) return { handler, abortCalls, promptCalls } @@ -148,19 +149,19 @@ describe("createEventHandler - model fallback", () => { const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: unsafeTestValue({ client: { tui: { showToast: async () => ({}), }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -168,7 +169,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) await handler({ @@ -358,19 +359,19 @@ describe("createEventHandler - model fallback", () => { const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: unsafeTestValue({ client: { tui: { showToast: async () => ({}), }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -378,7 +379,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) await handler({ @@ -449,7 +450,7 @@ describe("createEventHandler - model fallback", () => { setupConnectedProviderCacheMocks() const eventHandler = createEventHandler({ - ctx: { + ctx: unsafeTestValue({ directory: "/tmp", client: { session: { @@ -463,13 +464,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -477,14 +478,14 @@ describe("createEventHandler - model fallback", () => { skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: { + }), + hooks: unsafeTestValue({ modelFallback, - } as any, + }), }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: unsafeTestValue({ client: { tui: { showToast: async ({ body }: { body: { title?: string } }) => { @@ -493,13 +494,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -507,7 +508,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) const triggerRetryCycle = async () => { diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index a400118e6..a89f59ce9 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -802,6 +802,56 @@ describe("createEventHandler - event forwarding", () => { expect(forwardedEvents[0]?.event.type).toBe("message.part.delta") }) + it("forwards legacy message.part.updated activity with part-only session id to tmux session manager", async () => { + const forwardedEvents: EventInput[] = [] + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({}), + pluginConfig: asPluginConfig({ + tmux: { + enabled: true, + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, + isolation: "inline", + }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + skillMcpManager: { + disconnectSession: async () => {}, + }, + tmuxSessionManager: { + onEvent: (event: EventInput["event"]) => { + forwardedEvents.push({ event }) + }, + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + await eventHandler(asEventHandlerInput({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses_tmux_part_only", + type: "text", + text: "x", + }, + }, + }, + })) + expect(forwardedEvents.length).toBe(1) + expect(forwardedEvents[0]?.event.type).toBe("message.part.updated") + }) + it("does not forward tmux activity events when tmux integration is disabled", async () => { const forwardedEvents: EventInput[] = [] const eventHandler = createEventHandler({ diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 2b94fcadf..ade05ea93 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -47,6 +47,7 @@ import type { CreatedHooks } from "../create-hooks"; import type { Managers } from "../create-managers"; import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"; import { normalizeSessionStatusToIdle } from "./session-status-normalizer"; +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"; type FirstMessageVariantGate = { markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void; @@ -235,15 +236,15 @@ export function createEventHandler(args: { const getEventSessionID = (input: EventInput): string | undefined => { const properties = input.event.properties; - if ( - !properties || - typeof properties !== "object" || - !("sessionID" in properties) || - typeof properties.sessionID !== "string" - ) { - return undefined; + if (input.event.type.startsWith("session.")) { + return resolveSessionEventID(properties); } - return properties.sessionID; + if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) { + return resolveMessageEventSessionID(properties); + } + const record: Record | undefined = isRecord(properties) ? properties : undefined; + const sessionID = record?.sessionID; + return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined; }; const runEventHookSafely = async ( @@ -467,10 +468,11 @@ export function createEventHandler(args: { if (event.type === "session.created") { const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined; - const isSubagentSession = !!sessionInfo?.parentID || !!sessionInfo?.id && subagentSessions.has(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + const isSubagentSession = !!sessionInfo?.parentID || !!sessionID && subagentSessions.has(sessionID); if (!isSubagentSession) { - setMainSession(sessionInfo?.id); + setMainSession(sessionID); } firstMessageVariantGate.markSessionCreated(sessionInfo); @@ -489,62 +491,62 @@ export function createEventHandler(args: { // Skip subagent sessions — they are dispatched by specialized callbacks // in create-managers.ts (async) and tool-registry.ts (sync) - if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) { + if (pluginConfig.openclaw && sessionID && !isSubagentSession) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { - sessionId: sessionInfo.id, + sessionId: sessionID, projectPath: pluginContext.directory, - tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE, + tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { + const sessionID = resolveSessionEventID(props); + if (sessionID === getMainSessionID()) { setMainSession(undefined); } - if (sessionInfo?.id) { - const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id); - clearSessionAgent(sessionInfo.id); - lastHandledModelErrorMessageID.delete(sessionInfo.id); - lastHandledRetryStatusKey.delete(sessionInfo.id); - lastKnownModelBySession.delete(sessionInfo.id); + if (sessionID) { + const wasSyncSubagentSession = syncSubagentSessions.has(sessionID); + clearSessionAgent(sessionID); + lastHandledModelErrorMessageID.delete(sessionID); + lastHandledRetryStatusKey.delete(sessionID); + lastKnownModelBySession.delete(sessionID); if (modelFallback) { - clearPendingModelFallback(modelFallback, sessionInfo.id); - clearSessionFallbackChain(modelFallback, sessionInfo.id); + clearPendingModelFallback(modelFallback, sessionID); + clearSessionFallbackChain(modelFallback, sessionID); } - resetMessageCursor(sessionInfo.id); - clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id); - clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id); - firstMessageVariantGate.clear(sessionInfo.id); - clearSessionModel(sessionInfo.id); - clearSessionPromptParams(sessionInfo.id); - syncSubagentSessions.delete(sessionInfo.id); + resetMessageCursor(sessionID); + clearBackgroundOutputConsumptionsForParentSession(sessionID); + clearBackgroundOutputConsumptionsForTaskSession(sessionID); + firstMessageVariantGate.clear(sessionID); + clearSessionModel(sessionID); + clearSessionPromptParams(sessionID); + syncSubagentSessions.delete(sessionID); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { - sessionId: sessionInfo.id, + sessionId: sessionID, projectPath: pluginContext.directory, - tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE, + tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } if (wasSyncSubagentSession) { - subagentSessions.delete(sessionInfo.id); + subagentSessions.delete(sessionID); } - deleteSessionTools(sessionInfo.id); - await managers.skillMcpManager.disconnectSession(sessionInfo.id); + deleteSessionTools(sessionID); + await managers.skillMcpManager.disconnectSession(sessionID); await lspManager.cleanupTempDirectoryClients(); if (tmuxIntegrationEnabled) { await managers.tmuxSessionManager.onSessionDeleted({ - sessionID: sessionInfo.id, + sessionID, }); } } @@ -555,12 +557,12 @@ export function createEventHandler(args: { if (event.type === "message.removed") { const messageID = props?.messageID as string | undefined; - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveMessageEventSessionID(props); restoreBackgroundOutputConsumption(sessionID, messageID); } if (event.type === "session.idle" && pluginConfig.openclaw) { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, @@ -582,7 +584,7 @@ export function createEventHandler(args: { if (event.type === "message.updated") { const info = props?.info as Record | undefined; - const sessionID = info?.sessionID as string | undefined; + const sessionID = resolveMessageEventSessionID(props); const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; if (sessionID && info?.finish === true) { @@ -665,7 +667,7 @@ export function createEventHandler(args: { } if (event.type === "session.status") { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; // Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery @@ -733,7 +735,7 @@ export function createEventHandler(args: { if (event.type === "session.error") { try { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); const error = props?.error; const errorName = extractErrorName(error); @@ -818,7 +820,7 @@ export function createEventHandler(args: { } } } catch (err) { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); log("[event] model-fallback error in session.error:", { sessionID, error: err }); } diff --git a/src/plugin/fallback.cliproxyapi-matrix.test.ts b/src/plugin/fallback.cliproxyapi-matrix.test.ts index 3d1b5fd4c..089a2680c 100644 --- a/src/plugin/fallback.cliproxyapi-matrix.test.ts +++ b/src/plugin/fallback.cliproxyapi-matrix.test.ts @@ -11,6 +11,7 @@ import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types import { _resetForTesting } from "../features/claude-code-session-state" import { SessionCategoryRegistry } from "../shared/session-category-registry" import * as connectedProvidersCache from "../shared/connected-providers-cache" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" type EventHandlerArgs = Parameters[0] type ChatMessageHandlerArgs = Parameters[0] @@ -18,42 +19,42 @@ type HarnessContext = EventHandlerArgs["ctx"] & RuntimeFallbackPluginInput type HarnessEventInput = Parameters["eventHandler"]>[0] function asHarnessEventInput(input: unknown): HarnessEventInput { - return input as unknown as HarnessEventInput + return unsafeTestValue(input) } function asHarnessContext(ctx: unknown): HarnessContext { - return ctx as unknown as HarnessContext + return unsafeTestValue(ctx) } function createEventHandlerManagers( overrides: Record = {}, ): EventHandlerArgs["managers"] { - return { + return unsafeTestValue({ ...({} as EventHandlerArgs["managers"]), tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, ...overrides, - } as unknown as EventHandlerArgs["managers"] + }) } function createEventHandlerHooks( overrides: Record, ): EventHandlerArgs["hooks"] { - return { + return unsafeTestValue({ ...({} as EventHandlerArgs["hooks"]), ...overrides, - } as unknown as EventHandlerArgs["hooks"] + }) } function createChatMessageHandlerHooks( overrides: Record, ): ChatMessageHandlerArgs["hooks"] { - return { + return unsafeTestValue({ ...({} as ChatMessageHandlerArgs["hooks"]), ...overrides, - } as unknown as ChatMessageHandlerArgs["hooks"] + }) } const PRIMARY_MODEL = { @@ -87,7 +88,7 @@ let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined function createPluginConfig(mode: HarnessMode) { - return { + return unsafeTestValue({ agents: { sisyphus: { fallback_models: CLIPROXYAPI_FALLBACKS, @@ -100,7 +101,7 @@ function createPluginConfig(mode: HarnessMode) { }, } : {}), - } as unknown as EventHandlerArgs["pluginConfig"] + }) } function createHarness(args: { @@ -187,14 +188,14 @@ function createHarness(args: { timeout_seconds: args.sessionTimeoutMs ? 30 : 0, notify_on_fallback: false, }, - pluginConfig: pluginConfig as unknown as EventHandlerArgs["pluginConfig"], + pluginConfig: unsafeTestValue(pluginConfig), ...(args.sessionTimeoutMs ? { session_timeout_ms: args.sessionTimeoutMs } : {}), }) } const eventHandler = createEventHandler({ ctx, - pluginConfig: pluginConfig as unknown as EventHandlerArgs["pluginConfig"], + pluginConfig: unsafeTestValue(pluginConfig), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, @@ -209,7 +210,7 @@ function createHarness(args: { const chatMessageHandler = createChatMessageHandler({ ctx, - pluginConfig: pluginConfig as unknown as ChatMessageHandlerArgs["pluginConfig"], + pluginConfig: unsafeTestValue(pluginConfig), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, diff --git a/src/plugin/hooks/create-session-hooks.test.ts b/src/plugin/hooks/create-session-hooks.test.ts index ab6b5ad3b..42a091f4f 100644 --- a/src/plugin/hooks/create-session-hooks.test.ts +++ b/src/plugin/hooks/create-session-hooks.test.ts @@ -3,8 +3,9 @@ import type { OhMyOpenCodeConfig } from "../../config" import type { ModelCacheState } from "../../plugin-state" import type { PluginContext } from "../types" import { createSessionHooks } from "./create-session-hooks" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" -const mockContext = { +const mockContext = unsafeTestValue({ directory: "/tmp", client: { tui: { @@ -15,7 +16,7 @@ const mockContext = { update: async () => ({}), }, }, -} as unknown as PluginContext +}) const mockModelCacheState = {} as ModelCacheState diff --git a/src/plugin/session-status-normalizer.test.ts b/src/plugin/session-status-normalizer.test.ts index cfb99ec6d..043a85ce1 100644 --- a/src/plugin/session-status-normalizer.test.ts +++ b/src/plugin/session-status-normalizer.test.ts @@ -25,6 +25,7 @@ describe("normalizeSessionStatusToIdle", () => { type: "session.idle", properties: { sessionID: "ses_abc123", + synthetic: true, }, }, }) diff --git a/src/plugin/session-status-normalizer.ts b/src/plugin/session-status-normalizer.ts index e02377d3c..79c3bdb85 100644 --- a/src/plugin/session-status-normalizer.ts +++ b/src/plugin/session-status-normalizer.ts @@ -1,3 +1,5 @@ +import { resolveSessionEventID } from "../shared/event-session-id" + type EventInput = { event: { type: string; properties?: Record } } type SessionStatus = { type: string } @@ -10,13 +12,13 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu const status = props.status as SessionStatus | undefined if (!status || status.type !== "idle") return null - const sessionID = props.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return null return { event: { type: "session.idle", - properties: { sessionID }, + properties: { sessionID, synthetic: true }, }, } } diff --git a/src/plugin/tool-execute-before.ulw-loop.test.ts b/src/plugin/tool-execute-before.ulw-loop.test.ts index d4283c044..8a9994d1b 100644 --- a/src/plugin/tool-execute-before.ulw-loop.test.ts +++ b/src/plugin/tool-execute-before.ulw-loop.test.ts @@ -6,6 +6,7 @@ import { createToolExecuteAfterHandler } from "./tool-execute-after" import { createToolExecuteBeforeHandler } from "./tool-execute-before" import { ULTRAWORK_VERIFICATION_PROMISE } from "../hooks/ralph-loop/constants" import { clearState, readState, writeState } from "../hooks/ralph-loop/storage" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" describe("tool.execute.before ultrawork oracle verification", () => { function createCtx(directory: string) { @@ -56,7 +57,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const output = { args: createOracleTaskArgs("Check it") } @@ -78,7 +79,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { const directory = join(tmpdir(), `tool-before-ulw-${Date.now()}-plain`) mkdirSync(directory, { recursive: true }) const handler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const output = { args: createOracleTaskArgs("Check it") } @@ -96,8 +97,8 @@ describe("tool.execute.before ultrawork oracle verification", () => { mkdirSync(directory, { recursive: true }) const startLoopCalls: Array<{ sessionID: string; prompt: string; options: Record }> = [] const handler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], - hooks: { + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), + hooks: unsafeTestValue[0]["hooks"]>({ ralphLoop: { startLoop: (sessionID: string, prompt: string, options?: Record) => { startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) @@ -106,7 +107,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { cancelLoop: () => true, getState: () => null, }, - } as unknown as Parameters[0]["hooks"], + }), }) const output = { args: { @@ -148,7 +149,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const beforeHandler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const beforeOutput = { args: createOracleTaskArgs("Check it") } @@ -156,7 +157,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { const metadataFromSyncTask = createSyncTaskMetadata(beforeOutput.args, "ses-oracle") const handler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -191,7 +192,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -230,7 +231,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -269,11 +270,11 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const beforeHandler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const afterHandler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) diff --git a/src/plugin/ultrawork-model-override.test.ts b/src/plugin/ultrawork-model-override.test.ts index b37dc1285..1ec45978e 100644 --- a/src/plugin/ultrawork-model-override.test.ts +++ b/src/plugin/ultrawork-model-override.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import * as sharedModule from "../shared" import * as dbOverrideModule from "./ultrawork-db-model-override" import * as sessionStateModule from "../features/claude-code-session-state" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" let resolveUltraworkOverride: (typeof import("./ultrawork-model-override"))["resolveUltraworkOverride"] let detectUltrawork: (typeof import("./ultrawork-model-override"))["detectUltrawork"] @@ -70,11 +71,11 @@ describe("resolveUltraworkOverride", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return { + return unsafeTestValue[0]>({ agents: { [agentName]: { ultrawork }, }, - } as unknown as Parameters[0] + }) } test("should resolve override when ultrawork keyword detected", () => { @@ -139,9 +140,9 @@ describe("resolveUltraworkOverride", () => { test("should return null when agent has no ultrawork config", () => { //#given - const config = { + const config = unsafeTestValue[0]>({ agents: { sisyphus: { model: "anthropic/claude-sonnet-4-6" } }, - } as unknown as Parameters[0] + }) const output = createOutput("ultrawork do something") //#when @@ -278,11 +279,11 @@ describe("applyUltraworkModelOverrideOnMessage", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return { + return unsafeTestValue[0]>({ agents: { [agentName]: { ultrawork }, }, - } as unknown as Parameters[0] + }) } test("should schedule deferred DB override without variant when SDK unavailable", () => { diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts index 16a8ff60b..a44206c2e 100644 --- a/src/shared/binary-downloader.ts +++ b/src/shared/binary-downloader.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; import * as path from "node:path"; import { spawn } from "./bun-spawn-shim"; +import { bunWrite } from "./bun-file-shim"; import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator"; import { extractZip } from "./zip-extractor"; @@ -26,7 +27,7 @@ export async function downloadArchive(downloadUrl: string, archivePath: string): } const arrayBuffer = await response.arrayBuffer(); - await Bun.write(archivePath, arrayBuffer); + await bunWrite(archivePath, arrayBuffer); } export async function extractTarGz( diff --git a/src/shared/bun-file-shim.test.ts b/src/shared/bun-file-shim.test.ts new file mode 100644 index 000000000..7be9fa4d7 --- /dev/null +++ b/src/shared/bun-file-shim.test.ts @@ -0,0 +1,300 @@ +/// + +import { Buffer as NodeBuffer } from "node:buffer" +import { readFileSync } from "node:fs" +import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { afterAll, beforeAll, describe, expect, it } from "bun:test" + +import { bunFile, bunWrite } from "./bun-file-shim" + +type NodeFallbackBunFileLike = { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type NodeFallbackBunFile = (path: string) => NodeFallbackBunFileLike +type NodeFallbackBunWrite = (path: string, data: string | ArrayBuffer | Uint8Array) => Promise + +type NodeFallbackExports = { + bunFile: NodeFallbackBunFile + bunWrite: NodeFallbackBunWrite +} + +type BunFileTestRuntime = { + Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string } +} + +type BunFileSandbox = { + access: typeof access + Buffer: typeof NodeBuffer + console: Console + Promise: PromiseConstructor + readFile: typeof readFile + TextEncoder: typeof TextEncoder + Uint8Array: Uint8ArrayConstructor + unlink: typeof unlink + writeFile: typeof writeFile + __exports?: NodeFallbackExports +} + +const runtime = globalThis as typeof globalThis & { Bun: BunFileTestRuntime } +const NODE_FALLBACK = loadNodeFallbackBunFileShim() + +let temporaryDirectory = "" +let nodeFallbackTemporaryDirectory = "" + +function temporaryPath(fileName: string): string { + return join(temporaryDirectory, fileName) +} + +function nodeFallbackPath(fileName: string): string { + return join(nodeFallbackTemporaryDirectory, fileName) +} + +function loadNodeFallbackBunFileShim(): NodeFallbackExports { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-file-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const importStatement = 'import { access, readFile, unlink, writeFile } from "node:fs/promises"\n\n' + const interfaceSignature = "export interface BunFileLike {" + const bunFileSignature = "export function bunFile(path: string): BunFileLike {" + const bunWriteSignature = + "export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {" + + if (!source.startsWith(importStatement)) { + throw new Error("bun-file-shim import statement changed") + } + + for (const signature of [interfaceSignature, bunFileSignature, bunWriteSignature]) { + if (!source.includes(signature)) { + throw new Error(`bun-file-shim signature changed: ${signature}`) + } + } + + const transformedSource = source + .slice(importStatement.length) + .replace(interfaceSignature, "interface BunFileLike {") + .replace(bunFileSignature, "function bunFile(path: string): BunFileLike {") + .replace( + bunWriteSignature, + "async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {", + ) + const scriptSource = `${transformedSource}\nglobalThis.__exports = { bunFile, bunWrite }\n` + const transpiler = new runtime.Bun.Transpiler({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandbox: BunFileSandbox = { + access, + Buffer: NodeBuffer, + console, + Promise, + readFile, + TextEncoder, + Uint8Array, + unlink, + writeFile, + } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + if (!sandbox.__exports) { + throw new Error("Node fallback bun-file-shim loader failed") + } + + return sandbox.__exports +} + +function arrayBufferFromBytes(bytes: number[]): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(bytes.length) + const view = new Uint8Array(arrayBuffer) + + view.set(bytes) + + return arrayBuffer +} + +describe("bun-file-shim", () => { + beforeAll(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-")) + }) + + afterAll(async () => { + if (temporaryDirectory.length === 0) return + + await rm(temporaryDirectory, { recursive: true, force: true }) + }) + + describe("#given bunFile", () => { + it("#when text is called then it reads file contents", async () => { + const filePath = temporaryPath("text.txt") + const content = "hello from file" + + await writeFile(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = temporaryPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = temporaryPath("existing.txt") + const missingPath = temporaryPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await bunFile(existingPath).exists()).toBe(true) + expect(await bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = temporaryPath("delete-me.txt") + + await writeFile(filePath, "remove") + await bunFile(filePath).delete() + + expect(await bunFile(filePath).exists()).toBe(false) + }) + }) + + describe("#given bunWrite", () => { + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = temporaryPath("write-string.txt") + const content = "write me" + const bytesWritten = await bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = temporaryPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68]) + const bytesWritten = await bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = temporaryPath("round-trip.txt") + const content = "round trip content" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = temporaryPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + }) + + describe("#given Node fallback without Bun global", () => { + beforeAll(async () => { + nodeFallbackTemporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-node-")) + }) + + afterAll(async () => { + if (nodeFallbackTemporaryDirectory.length === 0) return + + await rm(nodeFallbackTemporaryDirectory, { recursive: true, force: true }) + }) + + it("#when text is called then it reads file contents", async () => { + const filePath = nodeFallbackPath("text.txt") + const content = "hello from Node fallback" + + await writeFile(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = nodeFallbackPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255, 128]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await NODE_FALLBACK.bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = nodeFallbackPath("existing.txt") + const missingPath = nodeFallbackPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await NODE_FALLBACK.bunFile(existingPath).exists()).toBe(true) + expect(await NODE_FALLBACK.bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = nodeFallbackPath("delete-me.txt") + + await writeFile(filePath, "remove") + await NODE_FALLBACK.bunFile(filePath).delete() + + expect(await NODE_FALLBACK.bunFile(filePath).exists()).toBe(false) + }) + + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = nodeFallbackPath("write-string.txt") + const content = "write me from Node fallback" + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = nodeFallbackPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68, 69]) + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68, 69]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = nodeFallbackPath("round-trip.txt") + const content = "round trip through Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = nodeFallbackPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍 from Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + }) +}) diff --git a/src/shared/bun-file-shim.ts b/src/shared/bun-file-shim.ts new file mode 100644 index 000000000..970853184 --- /dev/null +++ b/src/shared/bun-file-shim.ts @@ -0,0 +1,65 @@ +import { access, readFile, unlink, writeFile } from "node:fs/promises" + +export interface BunFileLike { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type BunFileRuntime = { + file(path: string): BunFileLike + write(path: string, data: string | ArrayBuffer | Uint8Array): Promise +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunFileRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function byteLength(data: string | ArrayBuffer | Uint8Array): number { + if (typeof data === "string") return Buffer.byteLength(data, "utf8") + + return data.byteLength +} + +function toWritableData(data: string | ArrayBuffer | Uint8Array): string | Uint8Array { + if (typeof data === "string") return data + if (data instanceof Uint8Array) return data + + return new Uint8Array(data) +} + +function createNodeFile(path: string): BunFileLike { + return { + text() { + return readFile(path, "utf8") + }, + async arrayBuffer() { + const buffer = await readFile(path) + + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + }, + exists() { + return access(path).then( + () => true, + () => false, + ) + }, + delete() { + return unlink(path) + }, + } +} + +export function bunFile(path: string): BunFileLike { + if (IS_BUN) return runtime.Bun!.file(path) + + return createNodeFile(path) +} + +export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise { + if (IS_BUN) return runtime.Bun!.write(path, data) + + await writeFile(path, toWritableData(data)) + + return byteLength(data) +} diff --git a/src/shared/bun-hash-shim.test.ts b/src/shared/bun-hash-shim.test.ts new file mode 100644 index 000000000..417553907 --- /dev/null +++ b/src/shared/bun-hash-shim.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { describe, expect, test } from "bun:test" +import { bunHashXxh32 as runtimeBunHashXxh32 } from "./bun-hash-shim" + +type HashFunction = (input: string, seed: number) => number +type HashPair = { input: string; seed: number } +type BunHashTestRuntime = { + hash: { xxHash32(data: string | Uint8Array, seed: number): number } + Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string } +} +type HashSandbox = { + Math: Math + TextEncoder: typeof TextEncoder + Uint8Array: Uint8ArrayConstructor + __bunHashShim?: { bunHashXxh32: HashFunction } +} + +const runtime = globalThis as typeof globalThis & { Bun: BunHashTestRuntime } +const FUZZ_PAIR_COUNT = 1_200 +const FIXED_LENGTHS = [0, 1, 2, 3, 4, 15, 16, 17, 31, 32, 33, 64, 100, 255, 500] +const FIXED_SEEDS = [0, 1, 42, 12345, 0xdeadbeef, 0xffffffff] +const CONTENT_FRAGMENTS = ["你好世界", "\u{1f389}", "\u{1f525}", "\n", "\r\n", "\t", " "] +const SPECIAL_INPUTS = [ + "", + " ", + "\t\n\r\n", + "hello world", + "你好世界", + "\u{1f389}\u{1f525}", + "mixed 你好 \u{1f389} ascii", + "line one\nline two\r\n\tindented", +] +const PURE_JS_HASH = loadPureJsBunHashXxh32() +const FUZZ_PAIRS = createFuzzPairs() + +function loadPureJsBunHashXxh32(): HashFunction { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-hash-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const exportSignature = "export function bunHashXxh32(input: string, seed: number): number {" + + if (!source.includes(exportSignature)) { + throw new Error("bunHashXxh32 export signature changed") + } + + const scriptSource = `${source.replace( + exportSignature, + "function bunHashXxh32(input: string, seed: number): number {", + )}\nglobalThis.__bunHashShim = { bunHashXxh32 }\n` + const transpiler = new runtime.Bun.Transpiler({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandbox: HashSandbox = { Math, TextEncoder, Uint8Array } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + const pureJsHash = sandbox.__bunHashShim?.bunHashXxh32 + if (!pureJsHash) { + throw new Error("pure-JS bunHashXxh32 loader failed") + } + + return pureJsHash +} + +function createUint32Generator(seed: number): () => number { + let state = seed >>> 0 + + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + + return state + } +} + +function createSeed(pairIndex: number, nextUint32: () => number): number { + if (pairIndex % (FIXED_SEEDS.length + 1) === FIXED_SEEDS.length) return nextUint32() + + return FIXED_SEEDS[pairIndex % FIXED_SEEDS.length] ?? 0 +} + +function createRandomString(length: number, nextUint32: () => number): string { + let value = "" + + while (value.length < length) { + if (nextUint32() % 10 < 6) { + value += String.fromCharCode(32 + (nextUint32() % 95)) + continue + } + + const fragment = CONTENT_FRAGMENTS[nextUint32() % CONTENT_FRAGMENTS.length] ?? " " + if (value.length + fragment.length <= length) { + value += fragment + continue + } + + value += String.fromCharCode(32 + (nextUint32() % 95)) + } + + return value +} + +function createFuzzPairs(): HashPair[] { + const nextUint32 = createUint32Generator(0x5eed1234) + const pairs: HashPair[] = [] + + for (const input of SPECIAL_INPUTS) { + pairs.push({ input, seed: createSeed(pairs.length, nextUint32) }) + } + + for (const length of FIXED_LENGTHS) { + pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) }) + } + + while (pairs.length < FUZZ_PAIR_COUNT) { + const randomLength = nextUint32() % 501 + const length = pairs.length % 13 === 0 ? (FIXED_LENGTHS[pairs.length % FIXED_LENGTHS.length] ?? randomLength) : randomLength + pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) }) + } + + return pairs +} + +function nativeXxh32(input: string, seed: number): number { + return runtime.Bun.hash.xxHash32(input, seed) +} + +function createMismatchMessage(label: string, input: string, seed: number, expected: number, actual: number): string { + return `${label} mismatch for input=${JSON.stringify(input)} seed=${seed} expected=${expected} actual=${actual}` +} + +function expectPureJsHashToMatchBun(label: string, input: string, seed: number): void { + const expected = nativeXxh32(input, seed) + const actual = PURE_JS_HASH(input, seed) + + if (actual !== expected) { + throw new Error(createMismatchMessage(label, input, seed, expected, actual)) + } +} + +describe("#given known XXH32 test vectors", () => { + test("#when pure-JS hash is called #then returns canonical values", () => { + expect(PURE_JS_HASH("", 0)).toBe(0x02cc5d05) + expect(PURE_JS_HASH("a", 0)).toBe(0x550d7456) + expect(PURE_JS_HASH("abc", 0)).toBe(0x32d153ff) + }) + + test("#when a non-zero seed is used #then matches Bun hash", () => { + expectPureJsHashToMatchBun("seeded vector", "test", 42) + expect(runtimeBunHashXxh32("test", 42)).toBe(nativeXxh32("test", 42)) + }) +}) + +describe("#given random inputs #when hashed with pure-JS and Bun.hash", () => { + test("#then all fuzz pairs are bit-exact", () => { + expect(FUZZ_PAIRS).toHaveLength(FUZZ_PAIR_COUNT) + + for (const [pairIndex, pair] of FUZZ_PAIRS.entries()) { + expectPureJsHashToMatchBun(`fuzz pair ${pairIndex}`, pair.input, pair.seed) + } + }) +}) + +describe("#given production-like inputs", () => { + test("#when hashed with line-number seeds #then pure-JS matches Bun hash", () => { + const inputs = [" const x = 42;", "import { foo } from 'bar'", "// comment", ""] + const seeds = [0, 1, 50, 100, 999] + + for (const input of inputs) { + for (const seed of seeds) { + expectPureJsHashToMatchBun("production-like input", input, seed) + } + } + }) +}) diff --git a/src/shared/bun-hash-shim.ts b/src/shared/bun-hash-shim.ts new file mode 100644 index 000000000..d77bbda10 --- /dev/null +++ b/src/shared/bun-hash-shim.ts @@ -0,0 +1,89 @@ +type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } } + +const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" +const encoder = new TextEncoder() + +const PRIME32_1 = 0x9e3779b1 +const PRIME32_2 = 0x85ebca77 +const PRIME32_3 = 0xc2b2ae3d +const PRIME32_4 = 0x27d4eb2f +const PRIME32_5 = 0x165667b1 + +function rotateLeft32(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0 +} + +function readUint32LittleEndian(input: Uint8Array, offset: number): number { + return ( + ((input[offset] ?? 0) | + ((input[offset + 1] ?? 0) << 8) | + ((input[offset + 2] ?? 0) << 16) | + ((input[offset + 3] ?? 0) << 24)) >>> + 0 + ) +} + +function round32(accumulator: number, value: number): number { + const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0 + + return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0 +} + +function xxHash32Js(input: Uint8Array, seed: number): number { + let offset = 0 + const length = input.length + let hash: number + + if (length >= 16) { + const limit = length - 16 + let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0 + let value2 = (seed + PRIME32_2) >>> 0 + let value3 = seed >>> 0 + let value4 = (seed - PRIME32_1) >>> 0 + + while (offset <= limit) { + value1 = round32(value1, readUint32LittleEndian(input, offset)) + offset += 4 + value2 = round32(value2, readUint32LittleEndian(input, offset)) + offset += 4 + value3 = round32(value3, readUint32LittleEndian(input, offset)) + offset += 4 + value4 = round32(value4, readUint32LittleEndian(input, offset)) + offset += 4 + } + + hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0 + hash = (hash + rotateLeft32(value3, 12)) >>> 0 + hash = (hash + rotateLeft32(value4, 18)) >>> 0 + } else { + hash = (seed + PRIME32_5) >>> 0 + } + + hash = (hash + length) >>> 0 + + while (offset + 4 <= length) { + hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0 + offset += 4 + } + + while (offset < length) { + hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0 + offset += 1 + } + + hash = (hash ^ (hash >>> 15)) >>> 0 + hash = Math.imul(hash, PRIME32_2) >>> 0 + hash = (hash ^ (hash >>> 13)) >>> 0 + hash = Math.imul(hash, PRIME32_3) >>> 0 + + return (hash ^ (hash >>> 16)) >>> 0 +} + +export function bunHashXxh32(input: string, seed: number): number { + if (IS_BUN) return runtime.Bun!.hash.xxHash32(input, seed) + + return xxHash32Js(encoder.encode(input), seed >>> 0) +} diff --git a/src/shared/bun-which-shim.test.ts b/src/shared/bun-which-shim.test.ts new file mode 100644 index 000000000..9b55fec8b --- /dev/null +++ b/src/shared/bun-which-shim.test.ts @@ -0,0 +1,149 @@ +import { accessSync, constants, readFileSync } from "node:fs" +import { delimiter, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { describe, expect, test } from "bun:test" + +import { bunWhich } from "./bun-which-shim" + +type BunWhichFunction = (commandName: string) => string | null +type BunWhichRuntime = { + Transpiler?: new (options: { loader: "ts" }) => { transformSync(source: string): string } + which(commandName: string): string | null +} +type SandboxProcess = { + env: { PATH?: string; Path?: string } + platform: typeof process.platform +} +type BunWhichSandbox = { + accessSync: typeof accessSync + constants: typeof constants + console: Console + delimiter: typeof delimiter + join: typeof join + process: SandboxProcess + __bunWhichShim?: { bunWhich: BunWhichFunction } +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const PATH_TRAVERSAL_COMMAND_NAMES = [ + "../etc/passwd", + "/etc/passwd", + "./tool", + "sub/dir/tool", + "C:\\Windows\\evil", + "C:tool", + ".", + "..", + "node..evil", +] +const NULL_BYTE_COMMAND_NAME = "node\0evil" +const NODE_FALLBACK_BUN_WHICH = loadNodeFallbackBunWhich() + +function loadNodeFallbackBunWhich(): BunWhichFunction { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-which-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const fsImport = 'import { accessSync, constants } from "node:fs"\n' + const pathImport = 'import { delimiter, join } from "node:path"\n' + const exportSignature = "export function bunWhich(commandName: string): string | null {" + + if (!source.includes(fsImport) || !source.includes(pathImport) || !source.includes(exportSignature)) { + throw new Error("bunWhich source shape changed") + } + + const scriptSource = `${source + .replace(fsImport, "") + .replace(pathImport, "") + .replace(exportSignature, "function bunWhich(commandName: string): string | null {")}\nglobalThis.__bunWhichShim = { bunWhich }\n` + const transpilerConstructor = runtime.Bun?.Transpiler + if (!transpilerConstructor) { + throw new Error("Bun Transpiler unavailable") + } + + const transpiler = new transpilerConstructor({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandboxProcess: SandboxProcess = { + env: { PATH: process.env.PATH, Path: process.env.Path }, + platform: process.platform, + } + const sandbox: BunWhichSandbox = { accessSync, constants, console, delimiter, join, process: sandboxProcess } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + const nodeFallbackBunWhich = sandbox.__bunWhichShim?.bunWhich + if (!nodeFallbackBunWhich) { + throw new Error("Node fallback bunWhich loader failed") + } + + return nodeFallbackBunWhich +} + +describe("bunWhich", () => { + test("#given 'node' command #when resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = bunWhich("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#given a non-existent command #when resolved #then returns null", () => { + const resolvedPath = bunWhich("this-command-definitely-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#given an empty string #when resolved #then returns null", () => { + const resolvedPath = bunWhich("") + + expect(resolvedPath).toBeNull() + }) + + test("#given the result for 'node' #when resolved #then the returned path matches Bun.which('node')", () => { + const nativePath = runtime.Bun?.which("node") + const shimPath = bunWhich("node") + + expect(nativePath).not.toBeNull() + expect(shimPath).toBe(nativePath) + }) + + test("#given path-traversal command names #when resolved through Bun runtime #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(bunWhich(commandName)).toBeNull() + } + }) + + test("#given a null-byte command name #when resolved through Bun runtime #then returns null", () => { + expect(bunWhich(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) + +describe("#given Node fallback bunWhich loaded without Bun global", () => { + test("#when 'node' command is resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#when a non-existent command is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("this-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#when an empty string is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("") + + expect(resolvedPath).toBeNull() + }) + + test("#when path-traversal command names are resolved #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(NODE_FALLBACK_BUN_WHICH(commandName)).toBeNull() + } + }) + + test("#when a null-byte command name is resolved #then returns null", () => { + expect(NODE_FALLBACK_BUN_WHICH(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) diff --git a/src/shared/bun-which-shim.ts b/src/shared/bun-which-shim.ts new file mode 100644 index 000000000..47d974918 --- /dev/null +++ b/src/shared/bun-which-shim.ts @@ -0,0 +1,58 @@ +import { accessSync, constants } from "node:fs" +import { delimiter, join } from "node:path" + +type BunWhichRuntime = { which(commandName: string): string | null } +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function isUnsafeCommandName(commandName: string): boolean { + if (commandName.includes("/") || commandName.includes("\\")) return true + if (commandName === "." || commandName === ".." || commandName.includes("..")) return true + if (/^[a-zA-Z]:/.test(commandName)) return true + if (commandName.includes("\0")) return true + + return false +} + +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +function resolvePathValue(): string | undefined { + if (process.platform === "win32") return process.env.Path ?? process.env.PATH + + return process.env.PATH +} + +function getWindowsCandidates(commandName: string): string[] { + if (process.platform !== "win32") return [commandName] + + return [commandName, `${commandName}.exe`, `${commandName}.cmd`, `${commandName}.bat`, `${commandName}.com`] +} + +export function bunWhich(commandName: string): string | null { + if (!commandName) return null + if (isUnsafeCommandName(commandName)) return null + if (IS_BUN) return runtime.Bun?.which(commandName) ?? null + + const pathValue = resolvePathValue() + if (!pathValue) return null + + const pathEntries = pathValue.split(delimiter).filter((pathEntry) => pathEntry.length > 0) + if (pathEntries.length === 0) return null + + const candidateNames = getWindowsCandidates(commandName) + for (const pathEntry of pathEntries) { + for (const candidateName of candidateNames) { + const candidatePath = join(pathEntry, candidateName) + if (isExecutable(candidatePath)) return candidatePath + } + } + + return null +} diff --git a/src/shared/dist-bundle-bun-globals.test.ts b/src/shared/dist-bundle-bun-globals.test.ts index 4d8b7a1d8..08cfc97a9 100644 --- a/src/shared/dist-bundle-bun-globals.test.ts +++ b/src/shared/dist-bundle-bun-globals.test.ts @@ -1,9 +1,58 @@ +/// + import { existsSync } from "node:fs" import { describe, expect, test } from "bun:test" const DIST_INDEX = "dist/index.js" const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm const TOP_LEVEL_REQUIRE_CALL = "__require(" +const RAW_BUN_API_CALL = /(? 120 ? `${content.slice(0, 117)}...` : content + + return `${lineNumber}: ${truncated}` +} describe("dist bundle Bun globals", () => { test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => { @@ -62,4 +111,66 @@ describe("dist bundle Bun globals", () => { stderr: "", }) }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned for raw Bun runtime APIs #then no unshimmed Bun API calls remain", async () => { + expect(hasRawBunApiCall("Bun.file('dist/index.js')")).toBe(true) + expect(hasRawBunApiCall("runtime.Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall(".Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall("$Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall("Bun.spawnSync.options")).toBe(true) + expect(hasRawBunApiCall("Bun.readableStreamToText(stream)")).toBe(true) + + const dist = await Bun.file(DIST_INDEX).text() + const offending: string[] = [] + let insideJSDoc = false + + for (const [index, line] of dist.split("\n").entries()) { + const trimmed = line.trimStart() + + if (insideJSDoc || trimmed.startsWith("/**")) { + insideJSDoc = !trimmed.includes("*/") + continue + } + + if (line.includes("runtime.Bun") || line.includes("globalThis.Bun") || line.includes("typeof Bun")) { + continue + } + + RAW_BUN_API_CALL.lastIndex = 0 + const rawMatch = [...line.matchAll(RAW_BUN_API_CALL)].find( + (match) => match.index !== undefined && !isInsideStringLiteral(line, match.index), + ) + + if (rawMatch) { + offending.push(formatOffendingLine(index + 1, line)) + } + } + + expect( + offending, + `Expected zero raw Bun API calls in dist/index.js but found ${offending.length}:\n${offending.join("\n")}`, + ).toEqual([]) + }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported and inspected under node --input-type=module #then stderr has no Bun reference errors", async () => { + const node = Bun.which("node") + if (!node) return + + const proc = Bun.spawn({ + cmd: [node, "--input-type=module", "-e", NODE_EXPORT_SMOKE_SCRIPT], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + const stderrLower = stderr.toLowerCase() + + expect(exitCode, stderr.trim()).toBe(0) + expect(stdout).toContain("SMOKE_OK:") + expect(stderrLower).not.toContain("referenceerror") + expect(stderr).not.toContain("Bun is not defined") + }) }) diff --git a/src/shared/event-session-id.test.ts b/src/shared/event-session-id.test.ts new file mode 100644 index 000000000..a1fa9c220 --- /dev/null +++ b/src/shared/event-session-id.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" + +import { resolveMessageEventSessionID, resolveSessionEventID } from "./event-session-id" + +describe("event session id resolvers", () => { + test("#given legacy message.part.updated properties #when resolving message session id #then part.sessionID is used", () => { + const sessionID = resolveMessageEventSessionID({ + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }) + + expect(sessionID).toBe("ses-part-only") + }) + + test("#given message.updated info id #when resolving message session id #then message id is not mistaken for session id", () => { + const sessionID = resolveMessageEventSessionID({ + info: { + id: "msg-not-session", + role: "assistant", + }, + }) + + expect(sessionID).toBeUndefined() + }) + + test("#given legacy session lifecycle properties #when resolving session id #then info.id is used", () => { + const sessionID = resolveSessionEventID({ + info: { + id: "ses-legacy-info-id", + }, + }) + + expect(sessionID).toBe("ses-legacy-info-id") + }) +}) diff --git a/src/shared/event-session-id.ts b/src/shared/event-session-id.ts new file mode 100644 index 000000000..734ff5d6a --- /dev/null +++ b/src/shared/event-session-id.ts @@ -0,0 +1,23 @@ +import { isRecord } from "./record-type-guard" + +function getStringField(record: Record | undefined, key: string): string | undefined { + const value = record?.[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +export function resolveSessionEventID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(info, "id") +} + +export function resolveMessageEventSessionID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + const part = isRecord(props?.part) ? props.part : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(part, "sessionID") +} diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts index a4e7e4750..19225086b 100644 --- a/src/shared/frontmatter.test.ts +++ b/src/shared/frontmatter.test.ts @@ -216,20 +216,23 @@ Body content` agent: string } + interface FrontmatterWithExtras extends MinimalMeta { + extra_field: string + another_extra: { nested: string; array: string[] } + custom_boolean: boolean + custom_number: number + } + // when - const result = parseFrontmatter(content) + const result = parseFrontmatter(content) // then expect(result.data.description).toBe("Test command") expect(result.data.agent).toBe("build") expect(result.body).toBe("Body content") - // @ts-expect-error - accessing extra field not in MinimalMeta expect(result.data.extra_field).toBe("should not fail") - // @ts-expect-error - accessing extra field not in MinimalMeta expect(result.data.another_extra).toEqual({ nested: "value", array: ["item1", "item2"] }) - // @ts-expect-error - accessing extra field not in MinimalMeta expect(result.data.custom_boolean).toBe(true) - // @ts-expect-error - accessing extra field not in MinimalMeta expect(result.data.custom_number).toBe(42) }) diff --git a/src/shared/git-worktree/collect-git-diff-stats.test.ts b/src/shared/git-worktree/collect-git-diff-stats.test.ts index e74148bf2..f16a28219 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.test.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" import * as childProcess from "node:child_process" import * as fs from "node:fs" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("collectGitDiffStats", () => { let execFileSyncSpy: ReturnType @@ -52,7 +53,7 @@ describe("collectGitDiffStats", () => { expect(execSyncSpy).not.toHaveBeenCalled() expect(execFileSyncSpy.mock.calls.length).toBeGreaterThanOrEqual(3) - const calls = execFileSyncSpy.mock.calls as unknown as Array<[string, string[], { cwd?: string }]> + const calls = unsafeTestValue>(execFileSyncSpy.mock.calls) const diffCall = calls.find(([, args]) => args[0] === "diff") const statusCall = calls.find(([, args]) => args[0] === "status") const untrackedCall = calls.find(([, args]) => args[0] === "ls-files") diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..9b29c6fbe 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -54,6 +54,7 @@ export * from "./fallback-model-availability" export * from "./connected-providers-cache" export * from "./context-limit-resolver" export * from "./session-utils" +export * from "./event-session-id" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" @@ -68,7 +69,9 @@ export * from "./opencode-message-dir" export * from "./opencode-command-dirs" export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" +export * from "./record-type-guard" export * from "./session-directory-resolver" +export * from "./session-route" export * from "./prompt-tools" export * from "./compaction-marker" export * from "./internal-initiator-marker" diff --git a/src/shared/migration/migrations-sidecar.ts b/src/shared/migration/migrations-sidecar.ts index 0cbac7db1..9491b3f8a 100644 --- a/src/shared/migration/migrations-sidecar.ts +++ b/src/shared/migration/migrations-sidecar.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs" import * as path from "node:path" import { log } from "../logger" +import { isRecord } from "../record-type-guard" import { writeFileAtomically } from "../write-file-atomically" /** @@ -48,14 +49,9 @@ export function readAppliedMigrations(configPath: string): Set { 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")) + const parsed: unknown = JSON.parse(content) + if (isRecord(parsed) && Array.isArray(parsed.appliedMigrations)) { + return new Set(parsed.appliedMigrations.filter((migration): migration is string => typeof migration === "string")) } return new Set() } catch (err) { diff --git a/src/shared/model-suggestion-retry.test.ts b/src/shared/model-suggestion-retry.test.ts index 698419a67..e594ad6a3 100644 --- a/src/shared/model-suggestion-retry.test.ts +++ b/src/shared/model-suggestion-retry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock } from "bun:test" import { parseModelSuggestion, promptWithModelSuggestionRetry, promptSyncWithModelSuggestionRetry } from "./model-suggestion-retry" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" describe("parseModelSuggestion", () => { describe("structured NamedError format", () => { @@ -217,7 +218,7 @@ describe("promptWithModelSuggestionRetry", () => { const client = { session: { promptAsync: promptMock } } // when calling promptWithModelSuggestionRetry - await promptWithModelSuggestionRetry(client as any, { + await promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -244,7 +245,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error without retrying await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { agent: "explore", @@ -267,7 +268,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the original error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -288,7 +289,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -307,7 +308,7 @@ describe("promptWithModelSuggestionRetry", () => { const client = { session: { promptAsync: promptMock } } // when calling with additional body fields - await promptWithModelSuggestionRetry(client as any, { + await promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { agent: "explore", @@ -341,7 +342,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -365,7 +366,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling without model in body // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -386,7 +387,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock, promptAsync: promptAsyncMock } } // when calling promptSyncWithModelSuggestionRetry - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -424,7 +425,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling with short timeout // then should abort the request and throw timeout error await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -451,7 +452,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock } } // when calling promptSyncWithModelSuggestionRetry - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -477,7 +478,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling promptSyncWithModelSuggestionRetry // then should throw the original error await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -504,7 +505,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling without model in body // then should throw (cannot retry without original model) await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -521,7 +522,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock } } // when calling with additional body fields - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { agent: "multimodal-looker", diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts index 3b1be1cf9..46fbc65b4 100644 --- a/src/shared/port-utils.test.ts +++ b/src/shared/port-utils.test.ts @@ -1,291 +1,393 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" -import { - isPortAvailable, - findAvailablePort, - getAvailableServerPort, - DEFAULT_SERVER_PORT, -} from "./port-utils" +import { createServer, Server } from "node:net" +import type { AddressInfo } from "node:net" +import { networkInterfaces } from "node:os" -const HOSTNAME = "127.0.0.1" -const REAL_PORT_SEARCH_WINDOW = 200 +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" -function supportsRealSocketBinding(): boolean { - try { - const server = Bun.serve({ - port: 0, - hostname: HOSTNAME, - fetch: () => new Response("probe"), +import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils" + +const DEFAULT_HOSTNAME = "127.0.0.1" +const MAX_PORT_ATTEMPTS = 20 +const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1 +const CONTIGUOUS_SEARCH_WINDOW = 256 +const CONTIGUOUS_SEARCH_SEEDS = 8 + +const trackedServers = new Set() + +type TimeoutProbeResult = { + closeCallCount: number + isAvailable: boolean + server: Server | undefined +} + +function getRequiredPropertyDescriptor(target: object, propertyName: string): PropertyDescriptor { + const descriptor = Object.getOwnPropertyDescriptor(target, propertyName) + if (!descriptor) { + throw new Error(`Expected ${propertyName} property descriptor`) + } + + return descriptor +} + +function isTcpAddress(address: ReturnType): address is AddressInfo { + return typeof address === "object" && address !== null && "port" in address +} + +function getServerPort(server: Server): number { + const address = server.address() + if (!isTcpAddress(address)) { + throw new Error("Expected TCP server address") + } + + return address.port +} + +function getAlternateIpv4Hostname(): string | undefined { + for (const addresses of Object.values(networkInterfaces())) { + if (!addresses) continue + + for (const address of addresses) { + if (address.family === "IPv4" && !address.internal && address.address !== DEFAULT_HOSTNAME) { + return address.address + } + } + } + + return undefined +} + +function startTrackedServer(port: number, hostname: string = DEFAULT_HOSTNAME): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + + const removeListeners = (): void => { + server.removeListener("error", handleError) + server.removeListener("listening", handleListening) + } + + const handleError = (error: Error): void => { + removeListeners() + trackedServers.delete(server) + reject(error) + } + + const handleListening = (): void => { + removeListeners() + trackedServers.add(server) + resolve(server) + } + + server.once("error", handleError) + server.once("listening", handleListening) + + try { + server.listen(port, hostname) + } catch (error) { + removeListeners() + trackedServers.delete(server) + reject(error) + } + }) +} + +function closeTrackedServer(server: Server): Promise { + trackedServers.delete(server) + + if (!server.listening) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error) + return + } + + resolve() }) - server.stop(true) + }) +} + +async function closeAllTrackedServers(): Promise { + await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server))) +} + +async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise { + const server = await startTrackedServer(0, hostname) + const port = getServerPort(server) + await closeTrackedServer(server) + + return port +} + +async function canBindContiguousPorts( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + return true } catch { return false + } finally { + await Promise.all(servers.map((server) => closeTrackedServer(server))) } } -const canBindRealSockets = supportsRealSocketBinding() +async function findContiguousAvailableStart( + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) { + const seedPort = await getReleasedPort(hostname) + const maxStartPort = Math.min(65_535 - portCount + 1, seedPort + CONTIGUOUS_SEARCH_WINDOW) -describe("port-utils", () => { - if (canBindRealSockets) { - function startRealBlocker(port: number = 0) { - return Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - } - - async function findContiguousAvailableStart(length: number): Promise { - const probe = startRealBlocker() - const seedPort = probe.port - probe.stop(true) - - for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) { - const checks = await Promise.all( - Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME)) - ) - if (checks.every(Boolean)) { - return candidate - } + for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) { + if (await canBindContiguousPorts(candidatePort, portCount, hostname)) { + return candidatePort } - - throw new Error(`Could not find ${length} contiguous available ports`) } - - describe("with real sockets", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const blocker = startRealBlocker() - const port = blocker.port - blocker.stop(true) - - const result = await isPortAvailable(port) - expect(result).toBe(true) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const blocker = startRealBlocker() - const port = blocker.port - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = await findContiguousAvailableStart(1) - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(startPort) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = await findContiguousAvailableStart(4) - const blockers = [ - startRealBlocker(startPort), - startRealBlocker(startPort + 1), - startRealBlocker(startPort + 2), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = await findContiguousAvailableStart(1) - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(preferredPort) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) - } else { - const blockedSockets = new Set() - let serveSpy: ReturnType - - function getSocketKey(port: number, hostname: string): string { - return `${hostname}:${port}` - } - - beforeEach(() => { - blockedSockets.clear() - serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => { - if (typeof port !== "number") { - throw new Error("Test expected numeric port") - } - const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME - const socketKey = getSocketKey(port, resolvedHostname) - - if (blockedSockets.has(socketKey)) { - const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & { - code?: string - syscall?: string - errno?: number - address?: string - port?: number - } - error.code = "EADDRINUSE" - error.syscall = "listen" - error.errno = 0 - error.address = resolvedHostname - error.port = port - throw error - } - - blockedSockets.add(socketKey) - return { - stop: (_force?: boolean) => { - blockedSockets.delete(socketKey) - }, - } as { stop: (force?: boolean) => void } - }) - }) - - afterEach(() => { - expect(blockedSockets.size).toBe(0) - serveSpy.mockRestore() - blockedSockets.clear() - }) - - describe("with mocked sockets fallback", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const port = 59999 - - const result = await isPortAvailable(port) - expect(result).toBe(true) - expect(blockedSockets.size).toBe(0) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const port = 59998 - const blocker = Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - - it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => { - const hostname = "192.0.2.10" - await isPortAvailable(59995, hostname) - - expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname) - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = 59997 - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = 59996 - const blocker = Bun.serve({ - port: startPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = 59993 - const blockers = [ - Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = 59990 - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = 59989 - const blocker = Bun.serve({ - port: preferredPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) } - describe("DEFAULT_SERVER_PORT", () => { - it("#given constant #when accessed #then returns 4096", () => { + throw new Error(`Could not find ${portCount} contiguous available ports`) +} + +async function startConsecutiveBlockers( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + + return servers + } catch (error) { + await Promise.all(servers.map((server) => closeTrackedServer(server))) + throw error + } +} + +async function captureDefaultListenHostname(port: number): Promise { + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + let capturedHostname: string | undefined + + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenAndCaptureHostname(this: Server, requestedPort: number, hostname?: string): Server { + if (requestedPort === port) { + capturedHostname = hostname + } + queueMicrotask(() => this.emit("listening")) + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeCapturedServer(this: Server, callback?: (error?: Error) => void): Server { + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + await isPortAvailable(port) + return capturedHostname + } finally { + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +async function runTimedOutAvailabilityProbe(port: number): Promise { + const setTimeoutDescriptor = getRequiredPropertyDescriptor(globalThis, "setTimeout") + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + const originalSetTimeout = globalThis.setTimeout + let timedOutServer: Server | undefined + let closeCallCount = 0 + + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (callback: () => void): ReturnType => originalSetTimeout(callback, 0), + }) + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenWithoutEmitting(this: Server): Server { + timedOutServer = this + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeTimedOutServer(this: Server, callback?: (error?: Error) => void): Server { + closeCallCount++ + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + const isAvailable = await isPortAvailable(port) + return { closeCallCount, isAvailable, server: timedOutServer } + } finally { + Object.defineProperty(globalThis, "setTimeout", setTimeoutDescriptor) + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +describe("port-utils", () => { + beforeAll(() => { + trackedServers.clear() + }) + + afterEach(async () => { + await closeAllTrackedServers() + }) + + afterAll(async () => { + await closeAllTrackedServers() + }) + + describe("#given isPortAvailable", () => { + test("#when a released port is checked #then returns true", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + + expect(result).toBe(true) + }) + + test("#when an already bound port is checked #then returns false", async () => { + const blocker = await startTrackedServer(0) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when a timed out probe is cleaned up #then no listeners or server remain active", async () => { + const port = await getReleasedPort() + + const result = await runTimedOutAvailabilityProbe(port) + + expect(result.isAvailable).toBe(false) + expect(result.closeCallCount).toBe(1) + expect(result.server).toBeDefined() + if (!result.server) { + throw new Error("Expected timed out server") + } + expect(result.server.listening).toBe(false) + expect(result.server.listenerCount("error")).toBe(0) + expect(result.server.listenerCount("listening")).toBe(0) + }) + + test("#when a successful probe finishes #then the port can be rebound immediately", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + const server = await startTrackedServer(port) + + expect(result).toBe(true) + expect(getServerPort(server)).toBe(port) + }) + + test("#when hostname is omitted #then 127.0.0.1 is the default target", async () => { + const blocker = await startTrackedServer(0, DEFAULT_HOSTNAME) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when another interface owns the port #then default probing does not bind all interfaces", async () => { + const alternateHostname = getAlternateIpv4Hostname() + + if (!alternateHostname) { + const port = await getReleasedPort() + const capturedHostname = await captureDefaultListenHostname(port) + expect(capturedHostname).toBe(DEFAULT_HOSTNAME) + return + } + + const blocker = await startTrackedServer(0, alternateHostname) + const port = getServerPort(blocker) + + expect(await isPortAvailable(port)).toBe(true) + expect(await isPortAvailable(port, alternateHostname)).toBe(false) + }) + }) + + describe("#given findAvailablePort", () => { + test("#when the start port is available #then returns the start port", async () => { + const startPort = await findContiguousAvailableStart(1) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort) + }) + + test("#when the first three ports are blocked #then returns the next free port", async () => { + const startPort = await findContiguousAvailableStart(4) + await startConsecutiveBlockers(startPort, 3) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort + 3) + }) + + test("#when every attempted port is blocked #then throws", async () => { + const startPort = await findContiguousAvailableStart(EXHAUSTED_PORT_COUNT) + await startConsecutiveBlockers(startPort, EXHAUSTED_PORT_COUNT) + + let errorMessage: string | undefined + try { + await findAvailablePort(startPort) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + errorMessage = error.message + } + + expect(errorMessage).toBe(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) + }) + }) + + describe("#given getAvailableServerPort", () => { + test("#when the preferred port is free #then returns the preferred port without auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(1) + + const result = await getAvailableServerPort(preferredPort) + + expect(result).toEqual({ port: preferredPort, wasAutoSelected: false }) + }) + + test("#when the preferred port is blocked #then returns the next port with auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(2) + await startTrackedServer(preferredPort) + + const result = await getAvailableServerPort(preferredPort) + expect(result).toEqual({ port: preferredPort + 1, wasAutoSelected: true }) + }) + }) + + describe("#given DEFAULT_SERVER_PORT", () => { + test("#when accessed #then returns 4096", () => { expect(DEFAULT_SERVER_PORT).toBe(4096) }) }) diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts index 978a2658c..94a908a97 100644 --- a/src/shared/port-utils.ts +++ b/src/shared/port-utils.ts @@ -1,18 +1,53 @@ +import { createServer } from "node:net" + const DEFAULT_SERVER_PORT = 4096 const MAX_PORT_ATTEMPTS = 20 +const PORT_CHECK_TIMEOUT_MS = 2000 export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { - try { - const server = Bun.serve({ - port, - hostname, - fetch: () => new Response(), + return new Promise((resolve) => { + const server = createServer() + let timeoutId: ReturnType | undefined + let resolved = false + + const finish = (isAvailable: boolean): void => { + if (resolved) { + return + } + resolved = true + if (timeoutId) { + clearTimeout(timeoutId) + } + server.removeAllListeners("error") + server.removeAllListeners("listening") + resolve(isAvailable) + } + + const closeThenFinish = (isAvailable: boolean): void => { + try { + server.close(() => finish(isAvailable)) + } catch { + finish(isAvailable) + } + } + + timeoutId = setTimeout(() => { + closeThenFinish(false) + }, PORT_CHECK_TIMEOUT_MS) + + server.once("error", () => { + finish(false) }) - server.stop(true) - return true - } catch { - return false - } + server.once("listening", () => { + closeThenFinish(true) + }) + + try { + server.listen(port, hostname) + } catch { + finish(false) + } + }) } export async function findAvailablePort( diff --git a/src/shared/session-route.ts b/src/shared/session-route.ts new file mode 100644 index 000000000..53901cf23 --- /dev/null +++ b/src/shared/session-route.ts @@ -0,0 +1,80 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, +} from "./model-suggestion-retry" + +type OpencodeClient = PluginInput["client"] + +type PromptAsyncArgs = Parameters[0] +type SessionMessagesArgs = Parameters[0] +type PromptRetryClient = Parameters[0] +type PromptRetryArgs = Parameters[1] +type PromptSyncRetryClient = Parameters[0] +type PromptSyncRetryArgs = Parameters[1] + +export function routeSessionPrompt(args: PromptAsyncArgs, directory: string): PromptAsyncArgs { + return { + ...args, + query: { directory }, + } +} + +export function routePromptRetry(args: PromptRetryArgs, directory: string): PromptRetryArgs { + return { + ...args, + query: { directory }, + } +} + +export function routePromptSyncRetry( + args: PromptSyncRetryArgs, + directory: string, +): PromptSyncRetryArgs { + return { + ...args, + query: { directory }, + } +} + +export function routeSessionMessages( + args: SessionMessagesArgs, + directory: string, +): SessionMessagesArgs { + return { + ...args, + query: { directory }, + } +} + +export function promptAsyncInDirectory( + client: OpencodeClient, + args: PromptAsyncArgs, + directory: string, +): Promise { + return client.session.promptAsync(routeSessionPrompt(args, directory)) +} + +export function promptWithRetryInDirectory( + client: PromptRetryClient, + args: PromptRetryArgs, + directory: string, +): Promise { + return promptWithModelSuggestionRetry(client, routePromptRetry(args, directory)) +} + +export function promptSyncWithRetryInDirectory( + client: PromptSyncRetryClient, + args: PromptSyncRetryArgs, + directory: string, +): Promise { + return promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(args, directory)) +} + +export function messagesInDirectory( + client: OpencodeClient, + args: SessionMessagesArgs, + directory: string, +): Promise { + return client.session.messages(routeSessionMessages(args, directory)) +} diff --git a/src/tools/background-task/create-background-task.metadata.test.ts b/src/tools/background-task/create-background-task.metadata.test.ts index d21e69c09..ae3142f68 100644 --- a/src/tools/background-task/create-background-task.metadata.test.ts +++ b/src/tools/background-task/create-background-task.metadata.test.ts @@ -6,6 +6,7 @@ import { describe, expect, mock, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" import { clearPendingStore, consumeToolMetadata } from "../../features/tool-metadata-store" import { createBackgroundTask } from "./create-background-task" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" @@ -18,7 +19,7 @@ describe("createBackgroundTask metadata", () => { // #given clearPendingStore() - const manager = { + const manager = unsafeTestValue({ launch: mock(() => Promise.resolve({ id: "task-1", sessionID: null, @@ -27,12 +28,12 @@ describe("createBackgroundTask metadata", () => { status: "pending", })), getTask: mock(() => undefined), - } as unknown as BackgroundManager - const client = { + }) + const client = unsafeTestValue({ session: { messages: mock(() => Promise.resolve({ data: [] })), }, - } as unknown as PluginInput["client"] + }) let capturedMetadata: { title?: string; metadata?: Record } | undefined const tool = createBackgroundTask(manager, client) diff --git a/src/tools/background-task/create-background-task.test.ts b/src/tools/background-task/create-background-task.test.ts index a7b588ff6..4dd36efe7 100644 --- a/src/tools/background-task/create-background-task.test.ts +++ b/src/tools/background-task/create-background-task.test.ts @@ -4,6 +4,7 @@ import { describe, test, expect, mock } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" import type { PluginInput } from "@opencode-ai/plugin" import { createBackgroundTask } from "./create-background-task" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("createBackgroundTask", () => { const launchMock = mock(async (): Promise<{ @@ -21,16 +22,16 @@ describe("createBackgroundTask", () => { })) const getTaskMock = mock() - const mockManager = { + const mockManager = unsafeTestValue({ launch: launchMock, getTask: getTaskMock, - } as unknown as BackgroundManager + }) - const mockClient = { + const mockClient = unsafeTestValue({ session: { messages: mock(() => Promise.resolve({ data: [] })), }, - } as unknown as PluginInput["client"] + }) const tool = createBackgroundTask(mockManager, mockClient) diff --git a/src/tools/background-task/tools.test.ts b/src/tools/background-task/tools.test.ts index 12404bf72..81969b431 100644 --- a/src/tools/background-task/tools.test.ts +++ b/src/tools/background-task/tools.test.ts @@ -6,6 +6,7 @@ import type { BackgroundManager, BackgroundTask } from "../../features/backgroun import type { ToolContext } from "@opencode-ai/plugin/tool" import type { BackgroundCancelClient, BackgroundOutputManager, BackgroundOutputClient } from "./tools" import { consumeToolMetadata, clearPendingStore } from "../../features/tool-metadata-store" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" @@ -66,10 +67,10 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = { + const ctxWithCallId = unsafeTestValue({ ...mockContext, callID: "call-1", - } as unknown as ToolContext + }) // #when await tool.execute({ task_id: "task-1" }, ctxWithCallId) @@ -93,10 +94,10 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = { + const ctxWithCallId = unsafeTestValue({ ...mockContext, callID: "call-1", - } as unknown as ToolContext + }) // #when await tool.execute({ task_id: "task-1" }, ctxWithCallId) @@ -387,7 +388,7 @@ describe("background_cancel", () => { // #given const task = createTask({ status: "running" }) const cancelled: string[] = [] - const manager = { + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string) => { @@ -395,7 +396,7 @@ describe("background_cancel", () => { task.status = "cancelled" return true }, - } as unknown as BackgroundManager + }) const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient const tool = createBackgroundCancel(manager, client) @@ -412,7 +413,7 @@ describe("background_cancel", () => { const taskA = createTask({ id: "task-a", status: "running" }) const taskB = createTask({ id: "task-b", status: "pending" }) const cancelled: string[] = [] - const manager = { + const manager = unsafeTestValue({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -421,7 +422,7 @@ describe("background_cancel", () => { task.status = "cancelled" return true }, - } as unknown as BackgroundManager + }) const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient const tool = createBackgroundCancel(manager, client) @@ -437,7 +438,7 @@ describe("background_cancel", () => { // #given const taskA = createTask({ id: "task-a", status: "running", sessionId: "ses-a", description: "running task" }) const taskB = createTask({ id: "task-b", status: "pending", sessionId: undefined, description: "pending task" }) - const manager = { + const manager = unsafeTestValue({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -445,7 +446,7 @@ describe("background_cancel", () => { task.status = "cancelled" return true }, - } as unknown as BackgroundManager + }) const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient const tool = createBackgroundCancel(manager, client) @@ -461,7 +462,7 @@ describe("background_cancel", () => { // #given const task = createTask({ id: "task-1", status: "running" }) const cancelOptions: Array<{ taskId: string; options: unknown }> = [] - const manager = { + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { @@ -469,7 +470,7 @@ describe("background_cancel", () => { task.status = "cancelled" return true }, - } as unknown as BackgroundManager + }) const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient const tool = createBackgroundCancel(manager, client) @@ -487,7 +488,7 @@ describe("background_cancel", () => { // #given const task = createTask({ id: "task-1", status: "running" }) const cancelOptions: Array<{ taskId: string; options: unknown }> = [] - const manager = { + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { @@ -495,7 +496,7 @@ describe("background_cancel", () => { task.status = "cancelled" return true }, - } as unknown as BackgroundManager + }) const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient const tool = createBackgroundCancel(manager, client) diff --git a/src/tools/call-omo-agent/agent-resolver.test.ts b/src/tools/call-omo-agent/agent-resolver.test.ts index f2e13022b..f2afa1f18 100644 --- a/src/tools/call-omo-agent/agent-resolver.test.ts +++ b/src/tools/call-omo-agent/agent-resolver.test.ts @@ -1,22 +1,8 @@ -/** - * Requirement-based tests for resolveCallableAgents(). - * - * These tests are derived from behavioral requirements in the PR description - * and feature spec, NOT from reading the implementation: - * - * R1: ALLOWED_AGENTS always present as baseline - * R2: Dynamic agents from client.app.agents() merged into the result - * R3: Primary-mode agents excluded from callable list - * R4: Falls back to ALLOWED_AGENTS alone when client.app.agents() fails - * R5: All output names are lowercase - * R6: No duplicate agent names in output - * R7: Malformed agent entries (null, missing name, non-string name, whitespace-only) are skipped gracefully - */ const { describe, test, expect, mock, beforeEach } = require("bun:test") const { resolveCallableAgents, clearCallableAgentsCache } = require("./agent-resolver") const { ALLOWED_AGENTS } = require("./constants") -function createMockClient(agents: Array>) { +function createMockClient(agents: Array> = []) { return { app: { agents: mock(() => Promise.resolve({ data: agents })), @@ -24,215 +10,54 @@ function createMockClient(agents: Array>) { } } -function createFailingClient(error: Error = new Error("API unavailable")) { - return { - app: { - agents: mock(() => Promise.reject(error)), - }, - } -} - describe("resolveCallableAgents", () => { beforeEach(() => { clearCallableAgentsCache() }) - describe("#given the SDK returns agents successfully", () => { - describe("#when only built-in agents exist", () => { - test("#then every ALLOWED_AGENT appears in the result", async () => { - const builtinAgents = ALLOWED_AGENTS.map((name: string) => ({ - name, - mode: "subagent", - })) - const client = createMockClient(builtinAgents) + describe("#given call_omo_agent is restricted to lookup agents", () => { + test("#then only ALLOWED_AGENTS are returned", async () => { + const client = createMockClient() - const result = await resolveCallableAgents(client) + const result = await resolveCallableAgents(client) - for (const agent of ALLOWED_AGENTS) { - expect(result).toContain(agent) - } - }) + expect(result).toEqual([...ALLOWED_AGENTS]) }) - describe("#when dynamic custom agents are present alongside built-ins", () => { - test("#then custom agents are included in the result", async () => { - const agents = [ - ...ALLOWED_AGENTS.map((name: string) => ({ name, mode: "subagent" })), - { name: "bug-fixer", mode: "subagent" }, - { name: "code-reviewer", mode: "subagent" }, - ] - const client = createMockClient(agents) + test("#then runtime custom agents are ignored and not queried", async () => { + const client = createMockClient([ + { name: "general", mode: "subagent" }, + { name: "bug-fixer", mode: "subagent" }, + ]) - const result = await resolveCallableAgents(client) + const result = await resolveCallableAgents(client) - expect(result).toContain("bug-fixer") - expect(result).toContain("code-reviewer") - }) - - test("#then ALLOWED_AGENTS are still present", async () => { - const agents = [{ name: "custom-agent", mode: "subagent" }] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - for (const agent of ALLOWED_AGENTS) { - expect(result).toContain(agent) - } - }) + expect(result).toEqual(["explore", "librarian"]) + expect(client.app.agents).not.toHaveBeenCalled() }) - describe("#when an agent has mode=primary", () => { - test("#then it is excluded from the callable list", async () => { - const agents = [ - { name: "sisyphus", mode: "primary" }, - { name: "explore", mode: "subagent" }, - ] - const client = createMockClient(agents) + test("#then non-lookup built-ins are not included", async () => { + const client = createMockClient([ + { name: "oracle", mode: "subagent" }, + { name: "hephaestus", mode: "subagent" }, + { name: "metis", mode: "subagent" }, + ]) - const result = await resolveCallableAgents(client) + const result = await resolveCallableAgents(client) - expect(result).not.toContain("sisyphus") - expect(result).toContain("explore") - }) + expect(result).not.toContain("oracle") + expect(result).not.toContain("hephaestus") + expect(result).not.toContain("metis") }) - describe("#when agent names have mixed case", () => { - test("#then all output names are lowercase", async () => { - const agents = [ - { name: "Bug-Fixer", mode: "subagent" }, - { name: "CODE-REVIEWER", mode: "subagent" }, - ] - const client = createMockClient(agents) + test("#then each call returns a defensive copy", async () => { + const client = createMockClient() - const result = await resolveCallableAgents(client) + const first = await resolveCallableAgents(client) + first.push("general") + const second = await resolveCallableAgents(client) - expect(result).toContain("bug-fixer") - expect(result).toContain("code-reviewer") - for (const name of result) { - expect(name).toBe(name.toLowerCase()) - } - }) - }) - - describe("#when duplicate agent names exist across sources", () => { - test("#then no duplicates appear in the result", async () => { - const agents = [ - { name: "explore", mode: "subagent" }, - { name: "explore", mode: "subagent" }, - { name: "Explore", mode: "subagent" }, - ] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - const exploreCount = result.filter((n: string) => n === "explore").length - expect(exploreCount).toBe(1) - }) - }) - - describe("#when agent entries are malformed", () => { - test("#then entries with null name are skipped", async () => { - const agents = [ - { name: null, mode: "subagent" }, - { name: "explore", mode: "subagent" }, - ] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - expect(result).toContain("explore") - expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length) - }) - - test("#then entries with numeric name are skipped", async () => { - const agents = [ - { name: 42, mode: "subagent" }, - { name: "explore", mode: "subagent" }, - ] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - expect(result).not.toContain("42") - expect(result).toContain("explore") - }) - - test("#then entries with whitespace-only name are skipped", async () => { - const agents = [ - { name: " ", mode: "subagent" }, - { name: "explore", mode: "subagent" }, - ] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - expect(result).not.toContain("") - expect(result).not.toContain(" ") - expect(result).toContain("explore") - }) - - test("#then entries with missing name property are skipped", async () => { - const agents = [ - { mode: "subagent" }, - { name: "explore", mode: "subagent" }, - ] - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - expect(result).toContain("explore") - expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length) - }) - - test("#then entries that are undefined/null themselves are skipped", async () => { - const agents = [ - null, - undefined, - { name: "explore", mode: "subagent" }, - ] as unknown as Array> - const client = createMockClient(agents) - - const result = await resolveCallableAgents(client) - - expect(result).toContain("explore") - }) - }) - - describe("#when SDK returns an empty list", () => { - test("#then ALLOWED_AGENTS still appear as the baseline", async () => { - const client = createMockClient([]) - - const result = await resolveCallableAgents(client) - - for (const agent of ALLOWED_AGENTS) { - expect(result).toContain(agent) - } - expect(result.length).toBe(ALLOWED_AGENTS.length) - }) - }) - }) - - describe("#given the SDK call fails", () => { - describe("#when client.app.agents() throws an error", () => { - test("#then it falls back to ALLOWED_AGENTS", async () => { - const client = createFailingClient(new Error("Network error")) - - const result = await resolveCallableAgents(client) - - expect(result.length).toBe(ALLOWED_AGENTS.length) - for (const agent of ALLOWED_AGENTS) { - expect(result).toContain(agent) - } - }) - - test("#then custom agents are NOT available in fallback mode", async () => { - const client = createFailingClient() - - const result = await resolveCallableAgents(client) - - expect(result).not.toContain("bug-fixer") - expect(result).not.toContain("custom-agent") - }) + expect(second).toEqual(["explore", "librarian"]) }) }) }) diff --git a/src/tools/call-omo-agent/agent-resolver.ts b/src/tools/call-omo-agent/agent-resolver.ts index 70bc4c32f..350057b72 100644 --- a/src/tools/call-omo-agent/agent-resolver.ts +++ b/src/tools/call-omo-agent/agent-resolver.ts @@ -1,64 +1,20 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { ALLOWED_AGENTS } from "./constants"; -import { normalizeSDKResponse } from "../../shared"; -import { log } from "../../shared/logger"; - -type AgentInfo = { - name: string; - mode?: "subagent" | "primary" | "all"; -}; - -const callableAgentsCache = new Map(); -const CACHE_TTL_MS = 30_000; export function clearCallableAgentsCache(): void { - callableAgentsCache.clear(); + // Kept for existing test setup and external callers; the resolver is now static. } /** - * Resolves the set of callable agent names at execute-time by merging the - * hardcoded `ALLOWED_AGENTS` with any additional agents discovered dynamically - * via `client.app.agents()`. Custom agents loaded from registered agent - * directories appear here alongside built-ins. + * Resolves the set of callable agent names for call_omo_agent. * - * Results are cached per session for 30s to avoid redundant SDK IPC calls. - * - * Falls back to `ALLOWED_AGENTS` alone if the dynamic lookup fails. - * - * @param client - The plugin client with access to the agent registry - * @param sessionId - Optional session ID for cache scoping - * @returns Array of lowercase callable agent names (excludes primary-mode agents) + * This tool is deliberately narrower than delegate-task: it may only launch + * the research lookup agents used by worker-style agents while they continue + * local work. Dynamic agents and other built-ins must go through task(). */ export async function resolveCallableAgents( - client: PluginInput["client"], - sessionId?: string, + _client?: PluginInput["client"], + _sessionId?: string, ): Promise { - const cacheKey = sessionId ?? "__default__"; - const cached = callableAgentsCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { - return cached.agents; - } - - try { - const agentsResult = await client.app.agents(); - const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], { - preferResponseOnMissingData: true, - }); - - const dynamicAgents = agents - .filter((a) => a && typeof a.name === "string" && a.name.trim().length > 0 && a.mode !== "primary") - .map((a) => a.name.trim().toLowerCase()); - - const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]); - const result = [...merged]; - callableAgentsCache.set(cacheKey, { agents: result, timestamp: Date.now() }); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log( - "[call_omo_agent] Failed to resolve dynamic agents, falling back to built-in list", - { error: message }, - ); - return [...ALLOWED_AGENTS]; - } + return [...ALLOWED_AGENTS]; } diff --git a/src/tools/call-omo-agent/agent-restriction.test.ts b/src/tools/call-omo-agent/agent-restriction.test.ts new file mode 100644 index 000000000..1ff7b0ca7 --- /dev/null +++ b/src/tools/call-omo-agent/agent-restriction.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, mock, test } from "bun:test" +import { createCallOmoAgent } from "./tools" +import { clearCallableAgentsCache } from "./agent-resolver" + +type AgentEntry = { + name: string + mode: "subagent" | "primary" | "all" +} + +function createPluginInput(agents: AgentEntry[]) { + return { + client: { + app: { + agents: mock(() => Promise.resolve({ data: agents })), + }, + }, + directory: "/test", + } +} + +function createBackgroundManager() { + const launch = mock(() => Promise.resolve({ + id: "task-id", + sessionId: "session-id", + description: "Test task", + agent: "explore", + status: "pending", + })) + + return { + manager: { + launch, + getTask: mock(() => undefined), + reserveSubagentSpawn: mock(() => Promise.resolve({ + spawnContext: { rootSessionID: "root", parentDepth: 0, childDepth: 1 }, + descendantCount: 1, + commit: mock(() => undefined), + rollback: mock(() => undefined), + })), + }, + launch, + } +} + +const toolContext = { + sessionID: "parent-session", + messageID: "message-id", + agent: "sisyphus-junior", + abort: new AbortController().signal, +} + +describe("call_omo_agent restricted agent set", () => { + test("#when runtime exposes general as a subagent #then call_omo_agent rejects it before launch", async () => { + //#given + clearCallableAgentsCache() + const pluginInput = createPluginInput([ + { name: "explore", mode: "subagent" }, + { name: "librarian", mode: "subagent" }, + { name: "general", mode: "subagent" }, + ]) + const { manager, launch } = createBackgroundManager() + const toolDefinition = createCallOmoAgent(pluginInput, manager) + + //#when + const result = await toolDefinition.execute( + { description: "Test", prompt: "Do work", subagent_type: "general", run_in_background: true }, + toolContext, + ) + + //#then + expect(result).toContain("Invalid agent type") + expect(result).toContain("Only explore, librarian are allowed") + expect(launch).not.toHaveBeenCalled() + }) + + test("#when caller requests oracle #then call_omo_agent rejects it because only research lookup agents are callable", async () => { + //#given + clearCallableAgentsCache() + const pluginInput = createPluginInput([ + { name: "explore", mode: "subagent" }, + { name: "librarian", mode: "subagent" }, + { name: "oracle", mode: "subagent" }, + ]) + const { manager, launch } = createBackgroundManager() + const toolDefinition = createCallOmoAgent(pluginInput, manager) + + //#when + const result = await toolDefinition.execute( + { description: "Test", prompt: "Review this", subagent_type: "oracle", run_in_background: true }, + toolContext, + ) + + //#then + expect(result).toContain("Invalid agent type") + expect(result).toContain("Only explore, librarian are allowed") + expect(launch).not.toHaveBeenCalled() + }) + + test("#when caller requests explore or librarian #then call_omo_agent still launches them", async () => { + //#given + clearCallableAgentsCache() + const pluginInput = createPluginInput([ + { name: "explore", mode: "subagent" }, + { name: "librarian", mode: "subagent" }, + ]) + const { manager, launch } = createBackgroundManager() + const toolDefinition = createCallOmoAgent(pluginInput, manager) + + //#when + await toolDefinition.execute( + { description: "Explore", prompt: "Read code", subagent_type: "explore", run_in_background: true }, + toolContext, + ) + await toolDefinition.execute( + { description: "Research", prompt: "Find docs", subagent_type: "librarian", run_in_background: true }, + toolContext, + ) + + //#then + expect(launch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/tools/call-omo-agent/constants.ts b/src/tools/call-omo-agent/constants.ts index 823313f2f..e09c7574a 100644 --- a/src/tools/call-omo-agent/constants.ts +++ b/src/tools/call-omo-agent/constants.ts @@ -1,18 +1,13 @@ export const ALLOWED_AGENTS = [ "explore", "librarian", - "oracle", - "hephaestus", - "metis", - "momus", - "multimodal-looker", ] as const -export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent or custom agents. run_in_background REQUIRED (true=async with task_id, false=sync). +export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in_background REQUIRED (true=async with task_id, false=sync). -Built-in agents: +Allowed agents: {agents} -Custom agents registered via user or project agent directories are also supported. +Other built-in agents, custom agents, and task categories are intentionally not supported by this tool. Pass \`session_id=\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.` diff --git a/src/tools/call-omo-agent/session-creator.test.ts b/src/tools/call-omo-agent/session-creator.test.ts index db231651d..222975c68 100644 --- a/src/tools/call-omo-agent/session-creator.test.ts +++ b/src/tools/call-omo-agent/session-creator.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { createOrGetSession } from "./session-creator" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("call-omo-agent createOrGetSession", () => { test("creates child session without overriding permission and tracks it as subagent session", async () => { @@ -37,12 +38,12 @@ describe("call-omo-agent createOrGetSession", () => { } // when - const result = await createOrGetSession(args as any, toolContext as any, ctx as any) + const result = await createOrGetSession(unsafeTestValue(args), unsafeTestValue(toolContext), unsafeTestValue(ctx)) // then expect(result).toEqual({ sessionID: "ses_child", isNew: true }) expect(createCalls).toHaveLength(1) - const createBody = (createCalls[0] as any)?.body + const createBody = (unsafeTestValue(createCalls[0]))?.body expect(createBody?.parentID).toBe("ses_parent") expect(createBody?.permission).toBeUndefined() expect(subagentSessions.has("ses_child")).toBe(true) diff --git a/src/tools/call-omo-agent/subagent-session-creator.test.ts b/src/tools/call-omo-agent/subagent-session-creator.test.ts index dea60d524..6e6f65e05 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.test.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { resolveOrCreateSessionId } from "./subagent-session-creator" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("call-omo-agent resolveOrCreateSessionId", () => { const originalPlatform = process.platform @@ -19,7 +20,7 @@ describe("call-omo-agent resolveOrCreateSessionId", () => { const { parentDirectory, contextDirectory } = options const parentSessionData = parentDirectory ? { data: { directory: parentDirectory } } : { data: {} } - const ctx = { + const ctx = unsafeTestValue[0]>({ directory: contextDirectory, client: { session: { @@ -31,7 +32,7 @@ describe("call-omo-agent resolveOrCreateSessionId", () => { }, }, }, - } as unknown as Parameters[0] + }) const args = { description: "sync test", diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index aab485706..9b8392b93 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -1,3 +1,4 @@ +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const { describe, test, expect, mock } = require("bun:test") type ExecuteSync = typeof import("./sync-executor").executeSync @@ -389,7 +390,7 @@ describe("executeSync", () => { } //#when - await executeSync(args, toolContext, ctx as any, deps, undefined, spawnReservation) + await executeSync(args, toolContext, unsafeTestValue(ctx), deps, undefined, spawnReservation) //#then expect(spawnReservation.commit).toHaveBeenCalledTimes(1) diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index 56e22a80a..31ae8beb8 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -14,6 +14,10 @@ type SessionWithPromptAsync = { promptAsync: (opts: { path: { id: string }; body: Record }) => Promise } +function hasPromptAsync(session: PluginInput["client"]["session"]): session is PluginInput["client"]["session"] & SessionWithPromptAsync { + return "promptAsync" in session && typeof session.promptAsync === "function" +} + type ExecuteSyncDeps = { createOrGetSession: typeof createOrGetSession waitForCompletion: typeof waitForCompletion @@ -102,7 +106,11 @@ export async function executeSync( const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type) try { - await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({ + if (!hasPromptAsync(ctx.client.session)) { + return `Error: Failed to send prompt: promptAsync is not available on this OpenCode client.\n\n\nsession_id: ${sessionID}\n` + } + + await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { agent: normalizedSubagentType, diff --git a/src/tools/call-omo-agent/tools-edge-cases.test.ts b/src/tools/call-omo-agent/tools-edge-cases.test.ts index e075389cf..b9d1082b6 100644 --- a/src/tools/call-omo-agent/tools-edge-cases.test.ts +++ b/src/tools/call-omo-agent/tools-edge-cases.test.ts @@ -1,10 +1,10 @@ /** * Requirement-based integration tests for createCallOmoAgent edge cases - * introduced by the dev rebase and dynamic agent resolution feature. + * around restricted agent validation and execution cleanup. * * R1: Spawn reservation is rolled back when execution fails after reservation - * R2: Agent names with leading/trailing whitespace are trimmed before matching - * R3: An agent present in both ALLOWED_AGENTS and dynamic list is callable (no conflict) + * R2: Dynamic runtime agents do not expand the call_omo_agent allowlist + * R3: An agent present in both ALLOWED_AGENTS and runtime results is callable * R4: session_id continuation rejects in background mode when session already exists */ const { describe, test, expect, mock, beforeEach } = require("bun:test") @@ -27,11 +27,6 @@ function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): Plu const DEFAULT_AGENTS = [ { name: "explore", mode: "subagent" }, { name: "librarian", mode: "subagent" }, - { name: "oracle", mode: "subagent" }, - { name: "hephaestus", mode: "subagent" }, - { name: "metis", mode: "subagent" }, - { name: "momus", mode: "subagent" }, - { name: "multimodal-looker", mode: "subagent" }, ] const reserveCommitMock = mock(() => 1) @@ -91,8 +86,8 @@ describe("createCallOmoAgent edge cases", () => { }) }) - describe("#given agent names with extra whitespace from SDK", () => { - test("#then whitespace-padded names are trimmed and matched correctly", async () => { + describe("#given a non-allowed agent appears in runtime agent results", () => { + test("#then the runtime agent is still rejected", async () => { const agents = [ ...DEFAULT_AGENTS, { name: " bug-fixer ", mode: "subagent" }, @@ -123,11 +118,12 @@ describe("createCallOmoAgent edge cases", () => { toolCtx, ) - expect(result).not.toContain("Invalid agent type") + expect(result).toContain("Invalid agent type") + expect(result).toContain("Only explore, librarian are allowed") }) }) - describe("#given an agent exists in both ALLOWED_AGENTS and dynamic results", () => { + describe("#given an agent exists in both ALLOWED_AGENTS and runtime results", () => { test("#then the agent is callable without conflict", async () => { const agents = [ ...DEFAULT_AGENTS, @@ -163,8 +159,8 @@ describe("createCallOmoAgent edge cases", () => { }) }) - describe("#given a disabled custom agent from dynamic resolution", () => { - test("#then disabled_agents check takes precedence over dynamic availability", async () => { + describe("#given a disabled custom agent appears in runtime results", () => { + test("#then restricted agent validation takes precedence over dynamic availability", async () => { const agents = [ ...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }, @@ -189,7 +185,8 @@ describe("createCallOmoAgent edge cases", () => { toolCtx, ) - expect(result).toContain("disabled via disabled_agents") + expect(result).toContain("Invalid agent type") + expect(result).not.toContain("disabled via disabled_agents") }) }) diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index bad411339..49d968225 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -35,11 +35,6 @@ function createFailingMockCtx(error: Error = new Error("API unavailable")): Plug const DEFAULT_AGENTS = [ { name: "explore", mode: "subagent" }, { name: "librarian", mode: "subagent" }, - { name: "oracle", mode: "subagent" }, - { name: "hephaestus", mode: "subagent" }, - { name: "metis", mode: "subagent" }, - { name: "momus", mode: "subagent" }, - { name: "multimodal-looker", mode: "subagent" }, ] const assertCanSpawnMock = mock(() => Promise.resolve(undefined)) @@ -135,7 +130,7 @@ describe("createCallOmoAgent", () => { }) }) - describe("dynamic custom agent resolution", () => { + describe("restricted agent validation", () => { test("should reject missing subagent_type without throwing", async () => { const mockCtx = createMockCtx(DEFAULT_AGENTS) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) @@ -149,22 +144,22 @@ describe("createCallOmoAgent", () => { expect(result).toContain("subagent_type is required") }) - test("should accept a custom agent returned by client.app.agents()", async () => { - const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }] + test("should reject general even when returned by client.app.agents()", async () => { + const agents = [...DEFAULT_AGENTS, { name: "general", mode: "subagent" }] const mockCtx = createMockCtx(agents) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) const executeFunc = toolDef.execute as Function const result = await executeFunc( - { description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true }, + { description: "Test", prompt: "Fix bug", subagent_type: "general", run_in_background: true }, toolCtx ) - expect(result).not.toContain("Invalid agent type") - expect(result).not.toContain("not found") + expect(result).toContain("Invalid agent type") + expect(result).toContain("Only explore, librarian are allowed") }) - test("should reject a custom agent NOT returned by client.app.agents()", async () => { + test("should reject unknown non-allowed agents", async () => { const mockCtx = createMockCtx(DEFAULT_AGENTS) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) const executeFunc = toolDef.execute as Function @@ -177,14 +172,13 @@ describe("createCallOmoAgent", () => { expect(result).toContain("Invalid agent type") }) - test("should perform case-insensitive matching for custom agents", async () => { - const agents = [...DEFAULT_AGENTS, { name: "Bug-Fixer", mode: "subagent" }] - const mockCtx = createMockCtx(agents) + test("should perform case-insensitive matching for allowed agents", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) const executeFunc = toolDef.execute as Function const result = await executeFunc( - { description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true }, + { description: "Test", prompt: "Explore", subagent_type: "EXPLORE", run_in_background: true }, toolCtx ) @@ -234,7 +228,7 @@ describe("createCallOmoAgent", () => { expect(result).toContain("Invalid agent type") }) - test("should still apply disabled_agents check to dynamically resolved custom agents", async () => { + test("should reject non-allowed agents before disabled_agents can make them appear callable", async () => { const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }] const mockCtx = createMockCtx(agents) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["bug-fixer"]) @@ -245,7 +239,8 @@ describe("createCallOmoAgent", () => { toolCtx ) - expect(result).toContain("disabled via disabled_agents") + expect(result).toContain("Invalid agent type") + expect(result).not.toContain("disabled via disabled_agents") }) }) diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 6e315cd86..2d8f4f405 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -122,7 +122,7 @@ export function createCallOmoAgent( subagent_type: tool.schema .string() .describe( - "The agent to invoke. Supports built-in agents and any custom agents registered at runtime.", + "The agent to invoke. Only explore and librarian are allowed.", ), run_in_background: tool.schema .boolean() diff --git a/src/tools/delegate-task/available-models.ts b/src/tools/delegate-task/available-models.ts index 711ac1920..9ec078cf7 100644 --- a/src/tools/delegate-task/available-models.ts +++ b/src/tools/delegate-task/available-models.ts @@ -1,7 +1,25 @@ import type { OpencodeClient } from "./types" import { log } from "../../shared/logger" +import { isRecord } from "../../shared/record-type-guard" import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" +type ModelListClient = OpencodeClient & { + model: { list: () => Promise } +} + +function hasModelList(client: OpencodeClient): client is ModelListClient { + return "model" in client && isRecord(client.model) && typeof client.model.list === "function" +} + +function isModelRow(value: unknown): value is { provider: string; id: string } { + return isRecord(value) && typeof value.provider === "string" && typeof value.id === "string" +} + +function extractModelRows(result: unknown): Array<{ provider: string; id: string }> { + const rows = Array.isArray(result) ? result : isRecord(result) && Array.isArray(result.data) ? result.data : [] + return rows.filter(isModelRow) +} + function addFromProviderModels( out: Set, providerID: string, @@ -35,24 +53,17 @@ export async function getAvailableModelsForDelegateTask(client: OpencodeClient): return new Set() } - const modelList = (client as unknown as { model?: { list?: () => Promise } }) - ?.model - ?.list - - if (!modelList) { + if (!hasModelList(client)) { return new Set() } try { - const result = await modelList() - const rows = Array.isArray(result) - ? result - : ((result as { data?: unknown }).data as Array<{ provider?: string; id?: string }> | undefined) ?? [] + const result = await client.model.list() + const rows = extractModelRows(result) const connected = new Set(connectedProviders) const out = new Set() for (const row of rows) { - if (!row?.provider || !row?.id) continue if (!connected.has(row.provider)) continue out.add(`${row.provider}/${row.id}`) } diff --git a/src/tools/delegate-task/category-resolver.test.ts b/src/tools/delegate-task/category-resolver.test.ts index 8077054d0..7fac923e2 100644 --- a/src/tools/delegate-task/category-resolver.test.ts +++ b/src/tools/delegate-task/category-resolver.test.ts @@ -3,6 +3,7 @@ const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require(" import { resolveCategoryExecution } from "./category-resolver" import type { ExecutorContext } from "./executor-types" import * as connectedProvidersCache from "../../shared/connected-providers-cache" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("resolveCategoryExecution", () => { let connectedProvidersSpy: ReturnType | undefined @@ -26,8 +27,8 @@ describe("resolveCategoryExecution", () => { }) const createMockExecutorContext = (): ExecutorContext => ({ - client: {} as any, - manager: {} as any, + client: unsafeTestValue({}), + manager: unsafeTestValue({}), directory: "/tmp/test", userCategories: {}, sisyphusJuniorModel: undefined, diff --git a/src/tools/delegate-task/metadata-await.test.ts b/src/tools/delegate-task/metadata-await.test.ts index 6592457c5..c9073889f 100644 --- a/src/tools/delegate-task/metadata-await.test.ts +++ b/src/tools/delegate-task/metadata-await.test.ts @@ -2,6 +2,7 @@ const { describe, test, expect } = require("bun:test") import { executeBackgroundTask } from "./executor" import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("task tool metadata awaiting", () => { test("executeBackgroundTask awaits ctx.metadata before returning", async () => { @@ -28,7 +29,7 @@ describe("task tool metadata awaiting", () => { subagent_type: "explore", } - const executorCtx = { + const executorCtx = unsafeTestValue({ manager: { launch: async () => ({ id: "task_1", @@ -40,7 +41,7 @@ describe("task tool metadata awaiting", () => { }), getTask: () => undefined, }, - } as any + }) const parentContext = { sessionID: "ses_parent", diff --git a/src/tools/delegate-task/metadata-model-unification.test.ts b/src/tools/delegate-task/metadata-model-unification.test.ts index 3a64022ab..0a23afa0d 100644 --- a/src/tools/delegate-task/metadata-model-unification.test.ts +++ b/src/tools/delegate-task/metadata-model-unification.test.ts @@ -2,6 +2,7 @@ const { describe, test, expect } = require("bun:test") import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" import type { ParentContext } from "./executor-types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const MODEL = { providerID: "anthropic", modelID: "claude-sonnet-4-6" } const MODEL_WITH_VARIANT = { providerID: "google", modelID: "gemini-3.1-pro", variant: "high" } @@ -63,7 +64,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -71,7 +72,7 @@ describe("metadata model unification", () => { }), getTask: () => undefined, }, - } as any, parentContext, "explore", MODEL, undefined) + }), parentContext, "explore", MODEL, undefined) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -92,7 +93,7 @@ describe("metadata model unification", () => { } await executeUnstableAgentTask( args, ctx, - { + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -109,7 +110,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -126,14 +127,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_2", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed", model: MODEL, }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -153,7 +154,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -162,7 +163,7 @@ describe("metadata model unification", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -206,7 +207,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -214,7 +215,7 @@ describe("metadata model unification", () => { }), getTask: () => undefined, }, - } as any, parentContext, "explore", undefined, undefined) + }), parentContext, "explore", undefined, undefined) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -236,7 +237,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - { + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -253,7 +254,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", undefined, undefined, "anthropic/claude-sonnet-4-6", ) @@ -270,14 +271,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_2", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed", }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -297,14 +298,14 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ data: [] }), prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -381,7 +382,7 @@ describe("metadata model unification", () => { category: "visual-engineering", load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_variant", description: "test", agent: "explore", @@ -389,7 +390,7 @@ describe("metadata model unification", () => { }), getTask: () => undefined, }, - } as any, parentContext, "explore", MODEL_WITH_VARIANT, undefined) + }), parentContext, "explore", MODEL_WITH_VARIANT, undefined) const meta = ctx.captured.find((metadataEvent: any) => metadataEvent.metadata?.sessionId) expect(meta).toBeDefined() @@ -411,7 +412,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - { + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -428,7 +429,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL_WITH_VARIANT, undefined, "google/gemini-3.1-pro high", ) @@ -445,14 +446,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_variant", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resume_variant", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed_variant", model: MODEL_WITH_VARIANT, }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((metadataEvent: any) => metadataEvent.metadata?.sessionId) expect(meta).toBeDefined() @@ -472,7 +473,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -481,7 +482,7 @@ describe("metadata model unification", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((metadataEvent: any) => metadataEvent.metadata?.sessionId) expect(meta).toBeDefined() diff --git a/src/tools/delegate-task/metadata-task-id-consistency.test.ts b/src/tools/delegate-task/metadata-task-id-consistency.test.ts index 23ce7d64a..3f466e6cb 100644 --- a/src/tools/delegate-task/metadata-task-id-consistency.test.ts +++ b/src/tools/delegate-task/metadata-task-id-consistency.test.ts @@ -2,6 +2,7 @@ const { describe, test, expect } = require("bun:test") import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" import type { ParentContext } from "./executor-types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const MODEL = { providerID: "anthropic", modelID: "claude-sonnet-4-6" } @@ -64,7 +65,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "explore", @@ -72,7 +73,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }), getTask: () => undefined, }, - } as any, parentContext, "explore", MODEL, undefined) + }), parentContext, "explore", MODEL, undefined) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -98,7 +99,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - { + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -115,7 +116,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -136,14 +137,14 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed_x", model: MODEL, }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -160,14 +161,14 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed_x", model: MODEL, category: "deep", }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -187,14 +188,14 @@ describe("taskId and backgroundTaskId metadata consistency", () => { task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", status: "running", sessionId: "ses_resumed_x", model: MODEL, }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -216,7 +217,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -225,7 +226,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((m: any) => m.metadata?.sessionId) expect(meta).toBeDefined() @@ -246,7 +247,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -255,7 +256,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -275,7 +276,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -284,7 +285,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -309,7 +310,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -318,7 +319,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { prompt: async () => ({}), }, }, - } as any, parentContext, deps) + }), parentContext, deps) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -368,7 +369,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { run_in_background: true, } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "Sisyphus-Junior", @@ -376,7 +377,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }), getTask: () => undefined, }, - } as any, parentContext, "Sisyphus-Junior", MODEL, undefined) + }), parentContext, "Sisyphus-Junior", MODEL, undefined) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -402,7 +403,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - { + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -419,7 +420,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "Sisyphus-Junior", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -438,14 +439,14 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resume_title", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resume_title", description: "continue work", agent: "explore", status: "running", sessionId: "ses_resume_title", model: MODEL, }), }, - } as any, parentContext) + }), parentContext) const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) expect(meta).toBeDefined() @@ -460,7 +461,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: false, task_id: "ses_sync_title", } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -469,7 +470,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { prompt: async () => ({}), }, }, - } as any, parentContext, { + }), parentContext, { pollSyncSession: async () => null, fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), }) @@ -500,8 +501,8 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, } - const bgOutput = createBackgroundOutput(manager as any, client as any) - await bgOutput.execute({ task_id: "bg_output_xyz" } as any, ctx as any) + const bgOutput = createBackgroundOutput(unsafeTestValue(manager), unsafeTestValue(client)) + await bgOutput.execute(unsafeTestValue({ task_id: "bg_output_xyz" }), unsafeTestValue(ctx)) const meta = ctx.captured.find((m: any) => m.metadata?.backgroundTaskId) expect(meta).toBeDefined() diff --git a/src/tools/delegate-task/sync-prompt-route.test.ts b/src/tools/delegate-task/sync-prompt-route.test.ts new file mode 100644 index 000000000..2ada42772 --- /dev/null +++ b/src/tools/delegate-task/sync-prompt-route.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, mock, test } from "bun:test" + +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import type { OpencodeClient } from "./types" +import { sendSyncPrompt } from "./sync-prompt-sender" +import { + promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, +} from "../../shared/model-suggestion-retry" + +type PromptRetryClient = Parameters[0] +type PromptRetryArgs = Parameters[1] +type PromptSyncRetryClient = Parameters[0] +type PromptSyncRetryArgs = Parameters[1] + +describe("sendSyncPrompt session routing", () => { + test("#given a sync child session directory #when sending the prompt #then promptAsync uses that OpenCode directory route", async () => { + // given + const promptCalls: PromptRetryArgs[] = [] + const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => { + promptCalls.push(input) + }) + + // when + await sendSyncPrompt( + unsafeTestValue({ session: {} }), + { + sessionID: "ses_child", + agentToUse: "sisyphus-junior", + args: { + description: "test task", + prompt: "test prompt", + run_in_background: false, + load_skills: [], + }, + systemContent: undefined, + categoryModel: undefined, + directory: "/parent/project", + toastManager: null, + taskId: undefined, + }, + { + promptWithModelSuggestionRetry: promptWithRetry, + promptSyncWithModelSuggestionRetry: mock(async () => {}), + }, + ) + + // then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent/project" }) + }) + + test("#given oracle falls back to promptSync #when async prompt returns unexpected EOF #then the sync retry keeps the same directory route", async () => { + // given + const promptSyncCalls: PromptSyncRetryArgs[] = [] + const promptWithRetry = mock(async () => { + throw new Error("JSON Parse error: Unexpected EOF") + }) + const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => { + promptSyncCalls.push(input) + }) + + // when + const result = await sendSyncPrompt( + unsafeTestValue({ session: {} }), + { + sessionID: "ses_child", + agentToUse: "oracle", + args: { + description: "test task", + prompt: "test prompt", + run_in_background: false, + load_skills: [], + }, + systemContent: undefined, + categoryModel: undefined, + directory: "/parent/project", + toastManager: null, + taskId: undefined, + }, + { + promptWithModelSuggestionRetry: promptWithRetry, + promptSyncWithModelSuggestionRetry: promptSyncWithRetry, + }, + ) + + // then + expect(result).toBeNull() + expect(promptSyncCalls).toHaveLength(1) + expect(promptSyncCalls[0]?.query).toEqual({ directory: "/parent/project" }) + }) +}) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index 1f8ad22a5..bfdac5fba 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -6,6 +6,7 @@ import { promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry, } from "../../shared/model-suggestion-retry" +import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route" import { formatDetailedError } from "./error-formatting" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names" @@ -59,6 +60,7 @@ export async function sendSyncPrompt( args: DelegateTaskArgs systemContent: string | undefined categoryModel: DelegatedModelConfig | undefined + directory: string toastManager: { removeTask: (id: string) => void } | null | undefined taskId: string | undefined sisyphusAgentConfig?: SisyphusAgentConfig @@ -99,11 +101,12 @@ export async function sendSyncPrompt( } try { - await deps.promptWithModelSuggestionRetry(client, promptArgs) + const routedPromptArgs = routePromptRetry(promptArgs, input.directory) + await deps.promptWithModelSuggestionRetry(client, routedPromptArgs) } catch (promptError) { if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) { try { - await deps.promptSyncWithModelSuggestionRetry(client, promptArgs) + await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory)) return null } catch (oracleRetryError) { promptError = oracleRetryError diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index dcc4b31e8..8978d933a 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -177,6 +177,7 @@ export async function executeSyncTask( agentToUse, args, systemContent, + directory: createSessionResult.parentDirectory, toastManager, taskId, sisyphusAgentConfig: executorCtx.sisyphusAgentConfig, diff --git a/src/tools/delegate-task/task-schema.test.ts b/src/tools/delegate-task/task-schema.test.ts index c50d175bc..fcad9cd4a 100644 --- a/src/tools/delegate-task/task-schema.test.ts +++ b/src/tools/delegate-task/task-schema.test.ts @@ -1,3 +1,4 @@ +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const { describe, expect, test } = require("bun:test") function requireFresh(modulePath: string): T { @@ -18,14 +19,14 @@ function createDelegateTask(...args: Parameters(toolDefinition.args.category) //#then expect(categorySchema.def.type).toBe("optional") diff --git a/src/tools/delegate-task/unstable-agent-permission.test.ts b/src/tools/delegate-task/unstable-agent-permission.test.ts index 50b96bad6..21defd5e0 100644 --- a/src/tools/delegate-task/unstable-agent-permission.test.ts +++ b/src/tools/delegate-task/unstable-agent-permission.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { executeUnstableAgentTask } from "./unstable-agent-task" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("executeUnstableAgentTask session permission", () => { test("passes question-deny session permission into background launch", async () => { @@ -33,7 +34,7 @@ describe("executeUnstableAgentTask session permission", () => { metadata: () => {}, abort: new AbortController().signal, } satisfies Parameters[1] - const executorContext = { + const executorContext = unsafeTestValue[2]>({ manager: mockManager, client: { session: { @@ -41,7 +42,7 @@ describe("executeUnstableAgentTask session permission", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as Parameters[2] + }) const parentContext = { sessionID: "parent-session", messageID: "msg_parent", diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index a6bf8da78..e5c31b67a 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -1,12 +1,13 @@ import { HASHLINE_DICT } from "./constants" import { createHashlineChunkFormatter } from "./hashline-chunk-formatter" +import { bunHashXxh32 } from "../../shared/bun-hash-shim" const RE_SIGNIFICANT = /[\p{L}\p{N}]/u function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string { const stripped = normalizedContent const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber - const hash = Bun.hash.xxHash32(stripped, seed) + const hash = bunHashXxh32(stripped, seed) const index = hash % 256 return HASHLINE_DICT[index] } diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index 54509ab6c..7b450b880 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -1,5 +1,6 @@ import type { ToolContext } from "@opencode-ai/plugin/tool" import { publishToolMetadata } from "../../features/tool-metadata-store" +import { bunFile, bunWrite } from "../../shared/bun-file-shim" import { applyHashlineEditsWithReport } from "./edit-operations" import { countLineDiffs, generateUnifiedDiff } from "./diff-utils" import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization" @@ -94,7 +95,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits) - const file = Bun.file(filePath) + const file = bunFile(filePath) const exists = await file.exists() if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) { return `Error: File not found: ${filePath}` @@ -102,7 +103,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T if (deleteMode) { if (!exists) return `Error: File not found: ${filePath}` - await Bun.file(filePath).delete() + await bunFile(filePath).delete() return `Successfully deleted ${filePath}` } @@ -122,11 +123,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T const writeContent = restoreFileText(canonicalNewContent, oldEnvelope) - await Bun.write(filePath, writeContent) + await bunWrite(filePath, writeContent) if (pluginCtx?.client) { await runFormattersForFile(pluginCtx.client as FormatterClient, context.directory, filePath) - const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8") + const formattedContent = Buffer.from(await bunFile(filePath).arrayBuffer()).toString("utf8") if (formattedContent !== writeContent) { const formattedEnvelope = canonicalizeFileText(formattedContent) const formattedMeta = buildSuccessMeta( @@ -138,8 +139,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T ) await publishToolMetadata(metadataContext, formattedMeta) if (rename && rename !== filePath) { - await Bun.write(rename, formattedContent) - await Bun.file(filePath).delete() + await bunWrite(rename, formattedContent) + await bunFile(filePath).delete() return `Moved ${filePath} to ${rename}` } return `Updated ${filePath}` @@ -147,8 +148,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T } if (rename && rename !== filePath) { - await Bun.write(rename, writeContent) - await Bun.file(filePath).delete() + await bunWrite(rename, writeContent) + await bunFile(filePath).delete() } const effectivePath = rename && rename !== filePath ? rename : filePath diff --git a/src/tools/hashline-edit/normalize-edits.test.ts b/src/tools/hashline-edit/normalize-edits.test.ts index 45cf6f253..a76b9f8fb 100644 --- a/src/tools/hashline-edit/normalize-edits.test.ts +++ b/src/tools/hashline-edit/normalize-edits.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("normalizeHashlineEdits", () => { it("maps replace with pos to replace", () => { @@ -51,9 +52,9 @@ describe("normalizeHashlineEdits", () => { it("rejects legacy payload without op", () => { //#given - const input = [{ type: "set_line", line: "2#VK", text: "updated" }] as unknown as Parameters< + const input = unsafeTestValue[0] + >[0]>([{ type: "set_line", line: "2#VK", text: "updated" }]) //#when / #then expect(() => normalizeHashlineEdits(input)).toThrow(/legacy format was removed/i) diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 1158ca3d2..686cc839f 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -6,16 +6,17 @@ import { canonicalizeFileText } from "./file-text-canonicalization" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createMockContext(): ToolContext { - return { + return unsafeTestValue({ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController().signal, metadata: mock(() => {}), ask: async () => {}, - } as unknown as ToolContext + }) } describe("createHashlineEditTool", () => { diff --git a/src/tools/look-at/multimodal-agent-metadata.test.ts b/src/tools/look-at/multimodal-agent-metadata.test.ts index aa057eb34..b7b5730ba 100644 --- a/src/tools/look-at/multimodal-agent-metadata.test.ts +++ b/src/tools/look-at/multimodal-agent-metadata.test.ts @@ -6,6 +6,7 @@ import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadat import { setVisionCapableModelsCache, clearVisionCapableModelsCache } from "../../shared/vision-capable-models-cache" import * as connectedProvidersCache from "../../shared/connected-providers-cache" import * as modelAvailability from "../../shared/model-availability" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" function createPluginInput(agentData: Array>): PluginInput { const client = {} as PluginInput["client"] @@ -32,8 +33,8 @@ describe("resolveMultimodalLookerAgentMetadata", () => { afterEach(() => { clearVisionCapableModelsCache() - ;(modelAvailability.fetchAvailableModels as unknown as { mockRestore?: () => void }).mockRestore?.() - ;(connectedProvidersCache.readConnectedProvidersCache as unknown as { mockRestore?: () => void }).mockRestore?.() + ;(unsafeTestValue<{ mockRestore?: () => void }>(modelAvailability.fetchAvailableModels)).mockRestore?.() + ;(unsafeTestValue<{ mockRestore?: () => void }>(connectedProvidersCache.readConnectedProvidersCache)).mockRestore?.() }) test("returns configured multimodal-looker model when it already matches a vision-capable override", async () => { diff --git a/src/tools/look-at/session-poller.test.ts b/src/tools/look-at/session-poller.test.ts index 757327a3d..cec05175e 100644 --- a/src/tools/look-at/session-poller.test.ts +++ b/src/tools/look-at/session-poller.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, mock } from "bun:test" import { pollSessionUntilIdle } from "./session-poller" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type SessionStatusResult = { data?: Record @@ -30,7 +31,7 @@ describe("pollSessionUntilIdle", () => { { data: { ses_test: { type: "idle" } } }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(unsafeTestValue(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(3) }) @@ -43,7 +44,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(unsafeTestValue(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(1) }) @@ -57,7 +58,7 @@ describe("pollSessionUntilIdle", () => { ]) await expect( - pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 50 }) + pollSessionUntilIdle(unsafeTestValue(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 50 }) ).rejects.toThrow("timed out") }) @@ -69,7 +70,7 @@ describe("pollSessionUntilIdle", () => { { error: new Error("API error") }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(unsafeTestValue(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(1) }) @@ -85,7 +86,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(unsafeTestValue(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(4) }) @@ -98,7 +99,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test") + await pollSessionUntilIdle(unsafeTestValue(client), "ses_test") expect(client.session.status).toHaveBeenCalledTimes(1) }) diff --git a/src/tools/look-at/tools.test.ts b/src/tools/look-at/tools.test.ts index 9067032de..56eda17f8 100644 --- a/src/tools/look-at/tools.test.ts +++ b/src/tools/look-at/tools.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, mock } from "bun:test" import type { ToolContext } from "@opencode-ai/plugin/tool" import { clearVisionCapableModelsCache, setVisionCapableModelsCache } from "../../shared/vision-capable-models-cache" import { normalizeArgs, validateArgs, createLookAt } from "./tools" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("look-at tool", () => { afterEach(() => { @@ -14,7 +15,7 @@ describe("look-at tool", () => { // then should normalize to file_path test("normalizes path to file_path for LLM compatibility", () => { const args = { path: "/some/file.png", goal: "analyze" } - const normalized = normalizeArgs(args as any) + const normalized = normalizeArgs(unsafeTestValue(args)) expect(normalized.file_path).toBe("/some/file.png") expect(normalized.goal).toBe("analyze") }) @@ -33,7 +34,7 @@ describe("look-at tool", () => { // then prefer file_path test("prefers file_path over path when both provided", () => { const args = { file_path: "/preferred.png", path: "/fallback.png", goal: "test" } - const normalized = normalizeArgs(args as any) + const normalized = normalizeArgs(unsafeTestValue(args)) expect(normalized.file_path).toBe("/preferred.png") }) @@ -42,7 +43,7 @@ describe("look-at tool", () => { // then preserve image_data in normalized args test("preserves image_data when provided", () => { const args = { image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "analyze" } - const normalized = normalizeArgs(args as any) + const normalized = normalizeArgs(unsafeTestValue(args)) expect(normalized.image_data).toBe("data:image/png;base64,iVBORw0KGgo=") expect(normalized.file_path).toBeUndefined() }) @@ -69,7 +70,7 @@ describe("look-at tool", () => { // when validated // then clear error message test("returns error when neither file_path nor image_data provided", () => { - const args = { goal: "analyze" } as any + const args = unsafeTestValue({ goal: "analyze" }) const error = validateArgs(args) expect(error).toContain("file_path") expect(error).toContain("image_data") @@ -88,7 +89,7 @@ describe("look-at tool", () => { // when validated // then clear error message test("returns error when goal is missing", () => { - const args = { file_path: "/some/path.png" } as any + const args = unsafeTestValue({ file_path: "/some/path.png" }) const error = validateArgs(args) expect(error).toContain("goal") expect(error).toContain("required") @@ -156,10 +157,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -193,10 +194,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -230,10 +231,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -291,10 +292,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -346,10 +347,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -395,10 +396,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -437,10 +438,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -486,10 +487,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -515,10 +516,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -539,10 +540,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -579,10 +580,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -632,10 +633,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -701,10 +702,10 @@ describe("look-at tool", () => { test("instructs agent to analyze attached file when Read is disabled (file_path mode)", async () => { const { mockClient, captured } = captureLastPromptBody() - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) await tool.execute( { file_path: "/test/file.png", goal: "describe contents" }, @@ -726,10 +727,10 @@ describe("look-at tool", () => { test("instructs agent to analyze attached image when image_data is provided", async () => { const { mockClient, captured } = captureLastPromptBody() - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) await tool.execute( { image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "describe image" }, @@ -751,10 +752,10 @@ describe("look-at tool", () => { test("explicitly warns the agent not to attempt Read when Read is disabled", async () => { const { mockClient, captured } = captureLastPromptBody() - const tool = createLookAt({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", - } as any) + })) await tool.execute( { file_path: "/test/file.pdf", goal: "extract text" }, diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts index f89de579f..d7d4f7c73 100644 --- a/src/tools/lsp/client.test.ts +++ b/src/tools/lsp/client.test.ts @@ -16,6 +16,7 @@ afterAll(() => { mock.restore() }) import { LSPClient, lspManager, validateCwd } from "./client" import type { ResolvedServer } from "./types" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("LSPClient", () => { beforeEach(async () => { @@ -36,7 +37,7 @@ describe("LSPClient", () => { const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return unsafeTestValue>(0) }) as typeof setTimeout const server: ResolvedServer = { @@ -50,7 +51,7 @@ describe("LSPClient", () => { // Stub protocol output: we only want to assert notifications. const sendNotificationSpy = spyOn( - client as unknown as { sendNotification: (m: string, p?: unknown) => void }, + unsafeTestValue<{ sendNotification: (m: string, p?: unknown) => void }>(client), "sendNotification" ) diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts index 91d940b94..634e66b2b 100644 --- a/src/tools/lsp/lsp-process.ts +++ b/src/tools/lsp/lsp-process.ts @@ -1,4 +1,4 @@ -import { spawn as bunSpawn } from "../../shared/bun-spawn-shim" +import { spawn as bunSpawn, type SpawnedProcess } from "../../shared/bun-spawn-shim" import { spawn as nodeSpawn, type ChildProcess } from "node:child_process" import { existsSync, statSync } from "fs" import { log } from "../../shared/logger" @@ -127,6 +127,30 @@ function wrapNodeProcess(proc: ChildProcess): UnifiedProcess { }, } } + +function wrapBunProcess(proc: SpawnedProcess): UnifiedProcess { + return { + stdin: { + write(chunk: Uint8Array | string) { + proc.stdin.write(chunk) + }, + }, + stdout: { + getReader: () => proc.stdout.getReader(), + }, + stderr: { + getReader: () => proc.stderr.getReader(), + }, + get exitCode() { + return proc.exitCode + }, + exited: proc.exited, + kill(signal?: string) { + proc.kill(signal === "SIGKILL" ? "SIGKILL" : undefined) + }, + } +} + export function spawnProcess( command: string[], options: { cwd: string; env: Record } @@ -154,5 +178,5 @@ export function spawnProcess( cwd: options.cwd, env: options.env, }) - return proc as unknown as UnifiedProcess + return wrapBunProcess(proc) } diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 1fbdb4e37..5a4ede7c2 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node: import { join } from "node:path" import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const TEST_DIR = join(tmpdir(), `omo-test-session-manager-${randomUUID()}`) const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message") @@ -448,7 +449,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { // Re-import to get fresh module with mocked isSqliteBackend const { setStorageClient, getMainSessions } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(unsafeTestValue[0]>(mockClient)) // when const sessions = await getMainSessions({ directory: "/test" }) @@ -473,7 +474,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, getAllSessions } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(unsafeTestValue[0]>(mockClient)) // when const sessionIDs = await getAllSessions() @@ -503,7 +504,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionMessages } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(unsafeTestValue[0]>(mockClient)) // when const messages = await readSessionMessages("ses_test") @@ -531,7 +532,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionTodos } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(unsafeTestValue[0]>(mockClient)) // when const todos = await readSessionTodos("ses_test") @@ -555,7 +556,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionMessages } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(unsafeTestValue[0]>(mockClient)) await expect(readSessionMessages("ses_test")).rejects.toThrow("API error") }) diff --git a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts index 32dd83bde..ccdfba648 100644 --- a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts +++ b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts @@ -12,6 +12,7 @@ import { clearSkillCache } from "../../../features/opencode-skill-loader/skill-c import type { LoadedSkill } from "../../../features/opencode-skill-loader/types" import type { CommandInfo } from "../../slashcommand/types" import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" +import { unsafeTestValue } from "../../../../test-support/unsafe-test-value" const originalReadFileSync = fs.readFileSync.bind(fs) @@ -205,7 +206,7 @@ describe("skill tool - agent restriction", () => { // given const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })] const tool = createSkillTool({ skills: loadedSkills }) - const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string } + const contextWithoutAgent = { ...mockContext, agent: unsafeTestValue(undefined) } // when / #then return expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow( diff --git a/test-setup.ts b/test-setup.ts index e66350edb..ccdfb0807 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -1,3 +1,4 @@ +/// import { afterEach, beforeEach, mock } from "bun:test" import { rmSync } from "node:fs" import { _resetForTesting as resetClaudeSessionState } from "./src/features/claude-code-session-state/state" diff --git a/test-support/unsafe-test-value.ts b/test-support/unsafe-test-value.ts new file mode 100644 index 000000000..faccd312e --- /dev/null +++ b/test-support/unsafe-test-value.ts @@ -0,0 +1,5 @@ +export function unsafeTestValue(value: TValue): TValue +export function unsafeTestValue(value: unknown): TValue +export function unsafeTestValue(value: unknown): TValue { + return value as TValue +} diff --git a/tests/hashline/test-edge-cases.ts b/tests/hashline/test-edge-cases.ts index b00b0302d..57a438491 100644 --- a/tests/hashline/test-edge-cases.ts +++ b/tests/hashline/test-edge-cases.ts @@ -12,7 +12,8 @@ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; // ── CLI arg passthrough ─────────────────────────────────────── const extraArgs: string[] = []; @@ -459,7 +460,6 @@ const TEST_CASES: TestCase[] = [ "Expected line 2 to be exactly 180 characters.", ].join(" "), validate: (content) => { - const expected = "L".repeat(180); const lines = content.replace(/\r/g, "").trimEnd().split("\n"); if (!lines[1]) { return { passed: false, reason: "line 2 is missing" }; @@ -880,6 +880,7 @@ const TEST_CASES: TestCase[] = [ // ── JSONL event types ───────────────────────────────────────── interface ToolCallEvent { + [key: string]: unknown; tool_call_id: string; tool_input: Record; tool_name: string; @@ -887,6 +888,7 @@ interface ToolCallEvent { } interface ToolResultEvent { + [key: string]: unknown; error?: string; output: string; tool_call_id: string; @@ -898,6 +900,28 @@ interface AnyEvent { [key: string]: unknown; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isToolCallEvent(event: AnyEvent): event is ToolCallEvent { + return ( + event.type === "tool_call" && + typeof event.tool_call_id === "string" && + typeof event.tool_name === "string" && + isRecord(event.tool_input) + ); +} + +function isToolResultEvent(event: AnyEvent): event is ToolResultEvent { + return ( + event.type === "tool_result" && + typeof event.tool_call_id === "string" && + typeof event.output === "string" && + (event.error === undefined || typeof event.error === "string") + ); +} + // ── Run single test case ───────────────────────────────────── async function runTestCase( tc: TestCase, @@ -913,7 +937,8 @@ async function runTestCase( writeFileSync(testFile, tc.fileContent, "utf-8"); } - const headlessScript = resolve(import.meta.dir, "headless.ts"); + const currentDirectory = dirname(fileURLToPath(import.meta.url)); + const headlessScript = resolve(currentDirectory, "headless.ts"); const headlessArgs = [ "run", headlessScript, @@ -976,12 +1001,8 @@ async function runTestCase( } } - const toolCalls = events.filter( - (e) => e.type === "tool_call" - ) as unknown as ToolCallEvent[]; - const toolResults = events.filter( - (e) => e.type === "tool_result" - ) as unknown as ToolResultEvent[]; + const toolCalls = events.filter(isToolCallEvent); + const toolResults = events.filter(isToolResultEvent); const editCalls = toolCalls.filter((e) => e.tool_name === "edit_file"); const editCallIds = new Set(editCalls.map((e) => e.tool_call_id)); diff --git a/tests/hashline/test-edit-ops.ts b/tests/hashline/test-edit-ops.ts index 05d63b4d2..357add621 100644 --- a/tests/hashline/test-edit-ops.ts +++ b/tests/hashline/test-edit-ops.ts @@ -12,7 +12,8 @@ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; // ── CLI arg passthrough ─────────────────────────────────────── const extraArgs: string[] = []; @@ -37,7 +38,6 @@ for (let i = 0; i < rawArgs.length; i++) { const BOLD = "\x1b[1m"; const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; -const YELLOW = "\x1b[33m"; const DIM = "\x1b[2m"; const CYAN = "\x1b[36m"; const RESET = "\x1b[0m"; @@ -45,7 +45,6 @@ const RESET = "\x1b[0m"; const pass = (msg: string) => console.log(` ${GREEN}✓${RESET} ${msg}`); const fail = (msg: string) => console.log(` ${RED}✗${RESET} ${msg}`); const info = (msg: string) => console.log(` ${DIM}${msg}${RESET}`); -const warn = (msg: string) => console.log(` ${YELLOW}⚠${RESET} ${msg}`); // ── Test case definition ───────────────────────────────────── interface TestCase { @@ -575,6 +574,7 @@ const TEST_CASES: TestCase[] = [ // ── JSONL event types ───────────────────────────────────────── interface ToolCallEvent { + [key: string]: unknown; tool_call_id: string; tool_input: Record; tool_name: string; @@ -582,6 +582,7 @@ interface ToolCallEvent { } interface ToolResultEvent { + [key: string]: unknown; error?: string; output: string; tool_call_id: string; @@ -593,6 +594,28 @@ interface AnyEvent { [key: string]: unknown; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isToolCallEvent(event: AnyEvent): event is ToolCallEvent { + return ( + event.type === "tool_call" && + typeof event.tool_call_id === "string" && + typeof event.tool_name === "string" && + isRecord(event.tool_input) + ); +} + +function isToolResultEvent(event: AnyEvent): event is ToolResultEvent { + return ( + event.type === "tool_result" && + typeof event.tool_call_id === "string" && + typeof event.output === "string" && + (event.error === undefined || typeof event.error === "string") + ); +} + // ── Run single test case ───────────────────────────────────── async function runTestCase( tc: TestCase, @@ -606,7 +629,8 @@ async function runTestCase( const testFile = join(testDir, tc.fileName); writeFileSync(testFile, tc.fileContent, "utf-8"); - const headlessScript = resolve(import.meta.dir, "headless.ts"); + const currentDirectory = dirname(fileURLToPath(import.meta.url)); + const headlessScript = resolve(currentDirectory, "headless.ts"); const headlessArgs = [ "run", headlessScript, @@ -669,12 +693,8 @@ async function runTestCase( } } - const toolCalls = events.filter( - (e) => e.type === "tool_call" - ) as unknown as ToolCallEvent[]; - const toolResults = events.filter( - (e) => e.type === "tool_result" - ) as unknown as ToolResultEvent[]; + const toolCalls = events.filter(isToolCallEvent); + const toolResults = events.filter(isToolResultEvent); const editCalls = toolCalls.filter((e) => e.tool_name === "edit_file"); const editCallIds = new Set(editCalls.map((e) => e.tool_call_id)); diff --git a/web/bun.lock b/web/bun.lock index 5eae7dbfa..644ca3eb3 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -22,10 +22,10 @@ "tailwindcss-animate": "^1.0.7", }, "devDependencies": { - "@opennextjs/cloudflare": "^1.19.8", + "@opennextjs/cloudflare": "^1.19.9", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", - "@types/node": "^25.6.2", + "@types/node": "^25.7.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "critters": "^0.0.25", @@ -39,11 +39,22 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "4.2.4", "typescript": "6.0.3", - "typescript-eslint": "^8.59.2", + "typescript-eslint": "^8.59.3", "wrangler": "^4.90.0", }, }, }, + "overrides": { + "brace-expansion": "5.0.6", + "fast-xml-parser": "5.7.3", + "flatted": "3.4.2", + "minimatch": "10.2.4", + "path-to-regexp": "8.4.2", + "picomatch": "4.0.4", + "postcss": "8.5.14", + "qs": "6.15.1", + "yaml": "2.9.0", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -325,10 +336,6 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], - - "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -371,6 +378,8 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@node-minify/core": ["@node-minify/core@8.0.6", "", { "dependencies": { "@node-minify/utils": "8.0.6", "glob": "9.3.5", "mkdirp": "1.0.4" } }, "sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g=="], "@node-minify/terser": ["@node-minify/terser@8.0.6", "", { "dependencies": { "@node-minify/utils": "8.0.6", "terser": "5.16.9" } }, "sha512-grQ1ipham743ch2c3++C8Isk6toJnxJSyDiwUI/IWUCh4CZFD6aYVw6UAY40IpCnjrq5aXGwiv5OZJn6Pr0hvg=="], @@ -385,9 +394,9 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - "@opennextjs/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="], + "@opennextjs/aws": ["@opennextjs/aws@4.0.2", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.18 <16 || >=16.2.6" }, "bin": { "open-next": "dist/index.js" } }, "sha512-nXQPT8GZDV+NWMJV9mD/Ywxyo+tCZfFvrR6jpEOYNcxK/AqiEDlJzPybGOrHUDWAYdR1b6tmh+MoR3VbqIao3w=="], - "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="], + "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.9", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.2", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.18 <16 || >=16.2.6", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-GUs+X25VFUqulzA0fALvUABWZ08zR1cpAPpREcNxhzVdhERe2OU3NslU25GsecV+0askV/w/NmE9PgpzENaAIg=="], "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], @@ -639,7 +648,7 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -651,19 +660,19 @@ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], @@ -767,7 +776,7 @@ "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], - "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -807,8 +816,6 @@ "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -963,7 +970,9 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -979,7 +988,7 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1063,7 +1072,7 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "icu-minify": ["icu-minify@4.11.0", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q=="], + "icu-minify": ["icu-minify@4.11.2", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-vZRLaDpZiFNBXZ1tUtekAf4WXl/Tow/BoWx8MWQqQpGckXf11opUXYzG+mXUj+OsDuv9Zz+etLfUWwxaMnYnzw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1251,7 +1260,7 @@ "next-intl": ["next-intl@4.11.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", "icu-minify": "^4.11.0", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.11.0", "po-parser": "^2.1.1", "use-intl": "^4.11.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA=="], - "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.11.0", "", {}, "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A=="], + "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.11.2", "", {}, "sha512-1TQGAjkrV6wl4gqwabCFLAAvkAvaBs87ByitYlu01bzWpD/pT/am1JYmpQCIdAMzzpF0hLtj3/xSgVWHjj9fmw=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], @@ -1303,19 +1312,21 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], @@ -1343,7 +1354,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1443,7 +1454,7 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1493,13 +1504,13 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], + "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1511,7 +1522,7 @@ "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], - "use-intl": ["use-intl@4.11.0", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.11.0", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA=="], + "use-intl": ["use-intl@4.11.2", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.11.2", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-JsheePHtkp39cDLbIbFr5Ta8jcPJM8g0qc5AVlTwsCtg/G98hqcCdFtEuDbZadK+8qSor6VLFTSNsUyZ0Zietw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1543,9 +1554,11 @@ "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -1601,10 +1614,28 @@ "@types/node-fetch/@types/node": ["@types/node@22.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + "cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "cloudflare/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], @@ -1617,12 +1648,6 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "eslint-plugin-import/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - - "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - - "eslint-plugin-react/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -1633,18 +1658,10 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "glob/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="], - "is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1655,6 +1672,10 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], + + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1665,8 +1686,6 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@node-minify/core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="], - "@node-minify/core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], "@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -1723,22 +1742,46 @@ "@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -1747,18 +1790,20 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "@node-minify/core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], - "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], - "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@node-minify/core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], } } diff --git a/web/package.json b/web/package.json index 7322363ba..eba323fc1 100644 --- a/web/package.json +++ b/web/package.json @@ -43,11 +43,22 @@ "safari >= 15", "edge >= 100" ], + "overrides": { + "brace-expansion": "5.0.6", + "fast-xml-parser": "5.7.3", + "flatted": "3.4.2", + "minimatch": "10.2.4", + "path-to-regexp": "8.4.2", + "picomatch": "4.0.4", + "postcss": "8.5.14", + "qs": "6.15.1", + "yaml": "2.9.0" + }, "devDependencies": { - "@opennextjs/cloudflare": "^1.19.8", + "@opennextjs/cloudflare": "^1.19.9", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", - "@types/node": "^25.6.2", + "@types/node": "^25.7.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "critters": "^0.0.25", @@ -61,7 +72,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "4.2.4", "typescript": "6.0.3", - "typescript-eslint": "^8.59.2", + "typescript-eslint": "^8.59.3", "wrangler": "^4.90.0" } }