From 5394b295893ccbfe3e5450a79c30eb647146bbae Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Mon, 11 May 2026 16:14:45 -0600 Subject: [PATCH 01/35] fix(deps): patch vulnerable mcp transitives --- bun.lock | 67 +++++++++++++++++++++++++++++----------------------- package.json | 10 ++++++-- 2 files changed, 45 insertions(+), 32 deletions(-) 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/package.json b/package.json index bc8dfaa6f..31c90024e 100644 --- a/package.json +++ b/package.json @@ -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", From 6b93fbfd65ddb1535b31e5c297cbfd6ad386d1c3 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Mon, 11 May 2026 16:18:19 -0600 Subject: [PATCH 02/35] ci: enforce frozen bun installs --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-platform.yml | 2 +- .github/workflows/publish.yml | 8 ++++---- .github/workflows/refresh-model-capabilities.yml | 2 +- .github/workflows/sisyphus-agent.yml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 361ce4c59..8ea728771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,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" @@ -59,7 +59,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 +84,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 From 33f62c4d36b2db9ae9311c12235bfe9ac336a8db Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Mon, 11 May 2026 16:37:47 -0600 Subject: [PATCH 03/35] fix(web): patch audit dependency advisories --- web/bun.lock | 159 ++++++++++++++++++++++++++++++----------------- web/package.json | 17 ++++- 2 files changed, 116 insertions(+), 60 deletions(-) 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" } } From 0de6bd7253932dd5d8dc87c7110cefe678f37995 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:06:49 +0900 Subject: [PATCH 04/35] fix(ralph-loop): reject promptAsync error responses --- .../continuation-prompt-injector.ts | 38 +++++- .../dispatch-failure-invariant.test.ts | 119 ++++++++++++++++++ src/shared/index.ts | 1 + 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index fc1405c74..85c74693a 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,37 @@ type MessageInfo = { tools?: Record } +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) + } +} + export async function injectContinuationPrompt( ctx: PluginInput, options: { @@ -77,7 +109,7 @@ export async function injectContinuationPrompt( : undefined const launchVariant = model?.variant - await ctx.client.session.promptAsync({ + const response = await ctx.client.session.promptAsync({ path: { id: options.sessionID }, body: { ...(cleanAgent !== undefined ? { agent: cleanAgent } : {}), @@ -88,6 +120,10 @@ export async function injectContinuationPrompt( }, query: { directory: options.directory }, }) + const promptError = extractPromptAsyncError(response) + if (promptError !== undefined) { + throw new Error(`promptAsync returned error: ${describePromptAsyncError(promptError)}`) + } log("[ralph-loop] continuation injected", { sessionID: options.sessionID }) } diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts index 096a01aad..a932fe4c5 100644 --- a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -77,6 +77,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 +245,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({ diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..3e981f974 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -68,6 +68,7 @@ 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 "./prompt-tools" export * from "./compaction-marker" From 4da48555ee4309c83fabfa73eaecb0766b4f82fb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 11:48:18 +0900 Subject: [PATCH 05/35] fix(plugin): normalize event session ids Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events. Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks. --- src/features/background-agent/manager.test.ts | 96 +++++++++++++++++++ src/features/background-agent/manager.ts | 29 +++--- .../session-idle-event-handler.ts | 8 +- src/features/tmux-subagent/manager.ts | 5 +- .../polling-manager-event-session-id.test.ts | 43 +++++++++ src/features/tmux-subagent/polling-manager.ts | 9 +- .../tmux-subagent/session-created-handler.ts | 5 +- src/hooks/agent-usage-reminder/hook.ts | 10 +- .../recovery-hook.ts | 15 +-- src/hooks/atlas/event-handler.ts | 23 ++--- src/hooks/atlas/index.test.ts | 32 +++++++ src/hooks/auto-slash-command/hook.ts | 14 +-- src/hooks/category-skill-reminder/hook.ts | 10 +- .../handlers/session-event-handler.ts | 19 ++-- src/hooks/compaction-context-injector/hook.ts | 10 +- .../compaction-context-injector/session-id.ts | 5 +- src/hooks/compaction-todo-preserver/hook.ts | 4 +- src/hooks/context-window-monitor.ts | 14 +-- src/hooks/directory-agents-injector/hook.ts | 12 +-- src/hooks/directory-readme-injector/hook.ts | 12 +-- src/hooks/interactive-bash-session/hook.ts | 4 +- src/hooks/preemptive-compaction.ts | 15 +-- src/hooks/ralph-loop/index.test.ts | 19 ++++ .../non-abort-error-continuation.test.ts | 71 ++++++++++++++ .../ralph-loop/ralph-loop-event-handler.ts | 20 ++-- src/hooks/ralph-loop/session-event-handler.ts | 11 ++- src/hooks/rules-injector/hook.ts | 10 +- src/hooks/runtime-fallback/event-handler.ts | 14 +-- .../message-update-handler.ts | 3 +- .../session-status-handler.ts | 3 +- .../session-notification-event-properties.ts | 9 ++ src/hooks/session-notification.test.ts | 41 ++++++++ src/hooks/session-notification.ts | 14 ++- src/hooks/stop-continuation-guard/hook.ts | 11 ++- src/hooks/task-reminder/hook.ts | 5 +- .../team-idle-wake-hint.ts | 4 +- .../team-lead-orphan-handler.ts | 4 +- .../team-member-error-handler.ts | 4 +- .../team-member-status-handler.ts | 7 +- src/hooks/think-mode/hook.ts | 7 +- .../todo-continuation-enforcer/handler.ts | 13 +-- .../non-idle-events.ts | 22 ++--- .../todo-continuation-enforcer.test.ts | 83 +++++++++++++--- .../unstable-agent-babysitter-hook.ts | 17 ++-- src/hooks/write-existing-file-guard/hook.ts | 4 +- src/plugin/event.test.ts | 50 ++++++++++ src/plugin/event.ts | 86 +++++++++-------- src/plugin/session-status-normalizer.ts | 4 +- src/shared/event-session-id.test.ts | 40 ++++++++ src/shared/event-session-id.ts | 23 +++++ src/shared/index.ts | 1 + 51 files changed, 740 insertions(+), 254 deletions(-) create mode 100644 src/features/tmux-subagent/polling-manager-event-session-id.test.ts create mode 100644 src/shared/event-session-id.test.ts create mode 100644 src/shared/event-session-id.ts 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..fdfa5849d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -18,6 +18,7 @@ import { resolveInheritedPromptTools, createInternalAgentTextPart, } 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" @@ -118,7 +119,7 @@ interface MessagePartInfo { interface EventProperties { sessionID?: string - info?: { id?: string } + info?: { id?: string; sessionID?: string } [key: string]: unknown } @@ -1260,8 +1261,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 +1281,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 +1314,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 +1322,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 +1406,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,7 +1421,7 @@ 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 + const sessionID = resolveSessionEventID(props) if (sessionID) { void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { log("[background-agent] Failed to flush pending parent wake:", { sessionID, error }) @@ -1440,7 +1442,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 +1471,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 +1530,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 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/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.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/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/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/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/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/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/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/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/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/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/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 4bda8eb0b..172363473 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -304,6 +304,25 @@ describe("ralph-loop", () => { expect(state?.iteration).toBe(2) }) + 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/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..c64cf2ef0 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" @@ -36,12 +37,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,20 +44,19 @@ 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 @@ -198,7 +192,7 @@ export function createRalphLoopEventHandler( } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (inFlightSessions.has(sessionID)) { @@ -389,7 +383,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/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/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/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 0c07405a0..b054ca5d8 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { hasVisibleAssistantResponse } from "./visible-assistant-response" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" export { hasVisibleAssistantResponse } from "./visible-assistant-response" @@ -17,7 +18,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.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/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/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/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/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/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/session-status-normalizer.ts b/src/plugin/session-status-normalizer.ts index e02377d3c..6089bf5c8 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,7 +12,7 @@ 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 { 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/index.ts b/src/shared/index.ts index e99234c33..0ea008554 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" From 4394f34225bfc128a264702c547921a1ed2efe92 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 06/35] feat(shim): add bun-file/hash/which shims with Node fallbacks The plugin builds with `bun build --target bun` and runs under OpenCode CLI (Bun SEA) but also under OpenCode Desktop (Electron / Node V8) where `globalThis.Bun` does not exist. Mirror the existing `bun-spawn-shim.ts` pattern for three more Bun runtime APIs: - bun-file-shim: bunFile()/bunWrite() backed by node:fs/promises with ArrayBuffer slicing to avoid Node Buffer pool exposure - bun-hash-shim: pure-JS XXH32, bit-exact with Bun.hash.xxHash32 verified by 1200-pair fuzz comparison so existing hashline LINE#ID tags remain stable across runtimes - bun-which-shim: synchronous PATH walker with Windows .exe/.cmd/.bat/.com extensions plus isUnsafeCommandName guard that rejects path separators, parent traversal, drive letters and null bytes before any probe Each shim uses the canonical `runtime.Bun !== undefined` detection and delegates to native Bun under IS_BUN, otherwise uses Node primitives. Each ships with a co-located test that exercises both branches via the `node:vm.runInNewContext` pattern from bun-hash-shim.test.ts. --- src/shared/bun-file-shim.test.ts | 300 ++++++++++++++++++++++++++++++ src/shared/bun-file-shim.ts | 65 +++++++ src/shared/bun-hash-shim.test.ts | 175 +++++++++++++++++ src/shared/bun-hash-shim.ts | 89 +++++++++ src/shared/bun-which-shim.test.ts | 149 +++++++++++++++ src/shared/bun-which-shim.ts | 58 ++++++ 6 files changed, 836 insertions(+) create mode 100644 src/shared/bun-file-shim.test.ts create mode 100644 src/shared/bun-file-shim.ts create mode 100644 src/shared/bun-hash-shim.test.ts create mode 100644 src/shared/bun-hash-shim.ts create mode 100644 src/shared/bun-which-shim.test.ts create mode 100644 src/shared/bun-which-shim.ts 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 +} From 0aafe20a85412e087ddc735aaa123d4a500b939a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 07/35] refactor: route raw Bun.file/write/hash/which/spawn through runtime shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates 19 unguarded `Bun.*` runtime call sites in the plugin bundle that crashed with `ReferenceError: Bun is not defined` under Electron. Per-tool-call hot paths (executed on every Read/Edit): - src/tools/hashline-edit/hash-computation.ts: Bun.hash.xxHash32 → bunHashXxh32 - src/tools/hashline-edit/hashline-edit-executor.ts: 8 sites via bunFile/bunWrite - src/hooks/hashline-read-enhancer/hook.ts: Bun.file → bunFile - src/hooks/hashline-edit-diff-enhancer/hook.ts: 2 sites via bunFile Plugin-load paths: - src/hooks/claude-code-hooks/config.ts and config-loader.ts: Bun.file → bunFile - src/features/claude-code-mcp-loader/loader.ts: Bun.file → bunFile - src/features/claude-code-plugin-loader/mcp-server-loader.ts: Bun.file → bunFile - src/features/team-mode/deps.ts: Bun.spawn → spawn shim - src/hooks/session-notification-utils.ts: Bun.which → bunWhich, also drops the bare `declare const Bun` ambient declaration - src/shared/binary-downloader.ts: Bun.write → bunWrite Pure mechanical API swaps. No control-flow or signature changes. --- src/features/claude-code-mcp-loader/loader.ts | 3 ++- .../mcp-server-loader.ts | 3 ++- src/features/team-mode/deps.ts | 3 ++- src/hooks/claude-code-hooks/config-loader.ts | 3 ++- src/hooks/claude-code-hooks/config.ts | 3 ++- src/hooks/hashline-edit-diff-enhancer/hook.ts | 5 +++-- src/hooks/hashline-read-enhancer/hook.ts | 3 ++- src/hooks/session-notification-utils.ts | 7 ++----- src/shared/binary-downloader.ts | 3 ++- src/tools/hashline-edit/hash-computation.ts | 3 ++- .../hashline-edit/hashline-edit-executor.ts | 17 +++++++++-------- 11 files changed, 30 insertions(+), 23 deletions(-) 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/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/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): 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/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/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/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 From 2386cbd9b916eae476b54c9a292bb404bbe8aa81 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 08/35] refactor(port-utils): drop Bun.serve in favor of node:net probe isPortAvailable() previously bound a one-shot Bun.serve and stopped it. That call was reachable from the plugin bundle through src/shared/index.ts barrel re-export and crashed on Electron. Switch to node:net.createServer().listen(port, host), which Bun fully implements as well. Adds a 2s safety timeout and removes both "error" and "listening" handlers on resolution to prevent listener leaks. Behavior is bit-equivalent: returns true iff a server can bind to (host, port) right now. Test file is fully rewritten away from stale Bun.serve mocking. New tests exercise: free-port detection via port 0, EADDRINUSE handling via a real net.createServer blocker, findAvailablePort range exhaustion, getAvailableServerPort auto-selection, 127.0.0.1 default hostname binding, and probe-server resource cleanup. --- src/shared/port-utils.test.ts | 650 ++++++++++++++++++++-------------- src/shared/port-utils.ts | 55 ++- 2 files changed, 421 insertions(+), 284 deletions(-) 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( From 22c7e4eb8ee5742c825e169dffffb85e7170e3e8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 09/35] refactor(mcp-oauth): replace Bun.serve with node:http in callback-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth callback server was using Bun.serve, which would crash if mcp-oauth code paths ever entered the plugin bundle. Switch to node:http.createServer with the same WHATWG behavior: - Binds to 127.0.0.1, preserves 200/400/404 status codes - Translates fetch(Request)→Response to (req, res) callback style - Clears OAuth timeout on success/error/missing-param paths - Replaces server.stop(true) with server.close() for shutdown Functional behavior and response bodies unchanged. --- src/features/mcp-oauth/callback-server.ts | 92 ++++++++++++++--------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 48dcb1729..64ff35a19 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,74 @@ 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 host = request.headers.host ?? "127.0.0.1" + const url = new URL(request.url ?? "/", `http://${host}`) - 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() }, } } From 11529394aa969d629a605e64e27aac942aafe850 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 10/35] refactor(cli): use Response(stream).text() instead of Bun.readableStreamToText bun-install.ts streamToText() was reachable from the plugin bundle via the cli/config-manager barrel re-export. Replace with the WHATWG standard `new Response(stream).text()` pattern which works identically in Bun and Node and avoids the last raw Bun.* runtime call in dist/index.js. --- src/cli/config-manager/bun-install.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 { From db3256baf619262e83617c04b3dacb39b977b0a3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 11/35] test: harden dist-bundle regression guard + Node smoke test Existing test only checked `globalThis.Bun` top-level destructures and `__require` calls. Add two new test cases: 1. Raw Bun runtime API scanner: scans dist/index.js for any `Bun.(` or `Bun..` call outside shim-safe patterns (runtime.Bun, globalThis.Bun, typeof Bun, and the "Bun is not defined" error-message string). Uses a negative lookbehind so shim indirection (`runtime.Bun.foo`) passes. 2. Node smoke test: imports dist/index.js under `node --input-type=module` and asserts stderr contains no `ReferenceError` and no `Bun is not defined`. The existing case 3 only checked exit code, which masked lazy-evaluation crashes that fire after import. Reading exports forces lazy module-level evaluation paths to run. --- src/shared/dist-bundle-bun-globals.test.ts | 111 +++++++++++++++++++++ 1 file changed, 111 insertions(+) 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") + }) }) From 100819f0bc9035cccd392a19a045ccd37bb9cf93 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 12/35] ci: build plugin before running tests The dist-bundle regression tests in src/shared/dist-bundle-bun-globals.test.ts are guarded by `test.skipIf(!existsSync("dist/index.js"))` and dist/ is gitignored, so they silently skipped in CI which ran tests before the build step. Adding the build step earlier ensures the regression guard runs and a future raw `Bun.*` leak in the bundle fails CI. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ea728771..693240673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: 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 From 8e07ef642ece552f16f6d8ee6becbb2a6b7d89e1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:54:24 +0900 Subject: [PATCH 13/35] fix(mcp-oauth): use fixed localhost base for callback URL parsing Cubic AI reviewer flagged the use of the untrusted Host header as the URL base in startCallbackServer. The server only binds to 127.0.0.1, so hardcoding "http://127.0.0.1" as the URL base is robust against malformed or manipulated Host values and matches upstream behavior prior to the node:http refactor. --- src/features/mcp-oauth/callback-server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 64ff35a19..0d4b3410b 100644 --- a/src/features/mcp-oauth/callback-server.ts +++ b/src/features/mcp-oauth/callback-server.ts @@ -57,8 +57,7 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro }, TIMEOUT_MS) const server = createServer((request: IncomingMessage, response: ServerResponse) => { - const host = request.headers.host ?? "127.0.0.1" - const url = new URL(request.url ?? "/", `http://${host}`) + const url = new URL(request.url ?? "/", "http://127.0.0.1") if (url.pathname !== "/oauth/callback") { response.statusCode = 404 From 2c0f8e4645bc3b2b768997451e7c6bc51ab791a4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:37:11 +0900 Subject: [PATCH 14/35] fix(ralph-loop): return prompt dispatch failures --- src/hooks/ralph-loop/completion-handler.ts | 44 ++++++++---- .../continuation-prompt-injector.test.ts | 57 +++++++++++++++ .../continuation-prompt-injector.ts | 52 ++++++++++---- .../dispatch-failure-invariant.test.ts | 70 +++++++++++++++++++ .../ralph-loop/iteration-continuation.ts | 10 ++- .../verification-failure-handler.ts | 16 ++++- 6 files changed, 221 insertions(+), 28 deletions(-) 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/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 85c74693a..4fd66c7d1 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -19,6 +19,10 @@ 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 @@ -50,6 +54,10 @@ function describePromptAsyncError(error: unknown): string { } } +function createPromptAsyncError(prefix: string, error: unknown): Error { + return new Error(`${prefix}: ${describePromptAsyncError(error)}`) +} + export async function injectContinuationPrompt( ctx: PluginInput, options: { @@ -59,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 @@ -109,21 +117,39 @@ export async function injectContinuationPrompt( : undefined const launchVariant = model?.variant - const 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 }, - }) + 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) { - throw new Error(`promptAsync returned error: ${describePromptAsyncError(promptError)}`) + 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 a932fe4c5..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" @@ -483,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/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/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, From d82f6a602463e5c43119070646d4979c7e05d9cf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 13:13:18 +0900 Subject: [PATCH 15/35] fix(background-agent): refresh stale todo state --- .../background-agent/manager.polling.test.ts | 41 +++++++++++++++++++ src/features/background-agent/manager.ts | 4 +- 2 files changed, 43 insertions(+), 2 deletions(-) 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.ts b/src/features/background-agent/manager.ts index fdfa5849d..efb812eb0 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1220,8 +1220,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 { From 2d20037d351565f1c65212d43c657f0d3fc525ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 04:48:25 +0000 Subject: [PATCH 16/35] @jas32096 has signed the CLA in code-yeongyu/oh-my-openagent#3966 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 07797e19759bf75f507e91a9df59530c31587559 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 15:28:35 +0900 Subject: [PATCH 17/35] fix(background-agent): preserve direct all-complete replies --- src/features/background-agent/manager.ts | 178 ++---------------- .../task-completion-cleanup.test.ts | 50 +++-- 2 files changed, 42 insertions(+), 186 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index efb812eb0..180c0a5fe 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -89,7 +89,6 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" -import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" type OpencodeClient = PluginInput["client"] @@ -100,15 +99,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 @@ -230,8 +220,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 @@ -1421,12 +1409,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 = resolveSessionEventID(props) - 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) => { @@ -2231,42 +2213,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 this.client.session.promptAsync({ + path: { id: task.parentSessionId }, + body: { + noReply: !shouldReply, + ...parentPromptContext, + parts: [createInternalAgentTextPart(notification)], + }, + }) + 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 { @@ -2288,112 +2258,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, @@ -2707,11 +2571,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) @@ -2724,7 +2583,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/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 884ec31d6..0212a5e17 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 } @@ -159,14 +160,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 +234,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 +245,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 +267,14 @@ 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 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 +285,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 +314,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 +338,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) From 9ffef79dbbf37d0e297ac623d74087a940237ed2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 15:28:46 +0900 Subject: [PATCH 18/35] fix(ralph-loop): ignore synthetic idle replays --- .../completion-promise-detector.test.ts | 23 ++++++++ .../ralph-loop/completion-promise-detector.ts | 4 +- src/hooks/ralph-loop/index.test.ts | 58 ++++++++++++++++++- .../ralph-loop/ralph-loop-event-handler.ts | 20 +++++++ src/plugin/session-status-normalizer.test.ts | 1 + src/plugin/session-status-normalizer.ts | 2 +- 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts index 814684068..d59151a7b 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.test.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts @@ -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/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 172363473..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,62 @@ 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()) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index c64cf2ef0..ddbef7fe6 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -12,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 @@ -62,6 +64,10 @@ function getRuntimeRetryActivitySessionID( return undefined } +function isSyntheticIdle(props: Record | undefined): boolean { + return props?.synthetic === true +} + function isAbortError(error: unknown): boolean { return typeof error === "object" && error !== null @@ -183,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 = resolveSessionEventID(props) if (!sessionID) return + const syntheticIdle = isSyntheticIdle(props) if (inFlightSessions.has(sessionID)) { log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID }) @@ -241,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, 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 6089bf5c8..79c3bdb85 100644 --- a/src/plugin/session-status-normalizer.ts +++ b/src/plugin/session-status-normalizer.ts @@ -18,7 +18,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu return { event: { type: "session.idle", - properties: { sessionID }, + properties: { sessionID, synthetic: true }, }, } } From cde14b5e37ecca8c1b8c07083686204f61835a75 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 15:28:57 +0900 Subject: [PATCH 19/35] fix(call-omo-agent): restrict callable agents --- docs/guide/orchestration.md | 2 +- .../call-omo-agent/agent-resolver.test.ts | 235 +++--------------- src/tools/call-omo-agent/agent-resolver.ts | 60 +---- .../call-omo-agent/agent-restriction.test.ts | 122 +++++++++ src/tools/call-omo-agent/constants.ts | 11 +- .../call-omo-agent/tools-edge-cases.test.ts | 27 +- src/tools/call-omo-agent/tools.test.ts | 31 +-- src/tools/call-omo-agent/tools.ts | 2 +- 8 files changed, 190 insertions(+), 300 deletions(-) create mode 100644 src/tools/call-omo-agent/agent-restriction.test.ts 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/src/tools/call-omo-agent/agent-resolver.test.ts b/src/tools/call-omo-agent/agent-resolver.test.ts index f2e13022b..773cd27aa 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 = []) { 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/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() From 4150092e9aa31a10065e2e478abe3d4db139d078 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 15:30:45 +0900 Subject: [PATCH 20/35] test: run suite through isolated mock runner --- package.json | 2 +- script/run-ci-tests.test.ts | 11 +++++++++++ src/index.telemetry.test.ts | 8 ++++++-- src/index.test.ts | 10 ++++++++-- src/index.ts | 4 +++- 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 script/run-ci-tests.test.ts diff --git a/package.json b/package.json index 31c90024e..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", 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/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" From 9c6090a208232a02d24817657433250bdb96ac5d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:21:31 +0900 Subject: [PATCH 21/35] test: add typed test coercion helper Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/testing/test-coerce-global.d.ts | 5 +++++ test-setup.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 src/testing/test-coerce-global.d.ts diff --git a/src/testing/test-coerce-global.d.ts b/src/testing/test-coerce-global.d.ts new file mode 100644 index 000000000..cc1e618be --- /dev/null +++ b/src/testing/test-coerce-global.d.ts @@ -0,0 +1,5 @@ +declare global { + function testCoerce(value: TValue): TValue +} + +export {} diff --git a/test-setup.ts b/test-setup.ts index e66350edb..6fbecdd57 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -11,6 +11,8 @@ const { restoreModuleMocks } = installModuleMockLifecycle(mock) let environmentSnapshot: NodeJS.ProcessEnv = { ...process.env } let workingDirectorySnapshot = process.cwd() +globalThis.testCoerce = (value: TValue): TValue => value + function cleanupOmoCacheDir(cacheDir: string): void { rmSync(cacheDir, { recursive: true, force: true }) } From 7365163885fb42d78c6e6f2fdbb0596e9be2869f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:21:39 +0900 Subject: [PATCH 22/35] test(cli): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/config-manager/npm-dist-tags.test.ts | 10 +-- .../config-manager/opencode-binary.test.ts | 12 +-- .../plugin-name-with-version.test.ts | 10 +-- src/cli/install.test.ts | 12 +-- src/cli/run/completion-continuation.test.ts | 84 +++++++++---------- .../run/completion-verbose-logging.test.ts | 4 +- src/cli/run/completion.test.ts | 4 +- src/cli/run/event-handlers.test.ts | 34 ++++---- src/cli/run/integration.test.ts | 16 ++-- src/cli/run/json-output.test.ts | 24 +++--- src/cli/run/poll-for-completion.test.ts | 16 ++-- src/cli/run/session-resolver.test.ts | 4 +- src/cli/run/timestamp-output.test.ts | 6 +- 13 files changed, 118 insertions(+), 118 deletions(-) diff --git a/src/cli/config-manager/npm-dist-tags.test.ts b/src/cli/config-manager/npm-dist-tags.test.ts index 3de417290..db592c363 100644 --- a/src/cli/config-manager/npm-dist-tags.test.ts +++ b/src/cli/config-manager/npm-dist-tags.test.ts @@ -13,12 +13,12 @@ describe("fetchNpmDistTags", () => { test("returns dist-tags on success", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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 +29,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 = testCoerce(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await fetchNpmDistTags("oh-my-openagent") @@ -40,12 +40,12 @@ describe("fetchNpmDistTags", () => { test("returns null on non-ok response", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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..2bc2f8b3f 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -92,12 +92,12 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = ((handler: TimerHandler) => { + const immediateSetTimeout = testCoerce(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return 1 as unknown as ReturnType - }) as unknown as typeof globalThis.setTimeout + return testCoerce>(1) + })) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const result = await getOpenCodeVersion() @@ -124,12 +124,12 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = ((handler: TimerHandler) => { + const immediateSetTimeout = testCoerce(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return 1 as unknown as ReturnType - }) as unknown as typeof globalThis.setTimeout + return testCoerce>(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..ca1a92f4d 100644 --- a/src/cli/config-manager/plugin-name-with-version.test.ts +++ b/src/cli/config-manager/plugin-name-with-version.test.ts @@ -13,12 +13,12 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical latest tag when current version matches latest", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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 +29,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 = testCoerce(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await getPluginNameWithVersion("3.14.0-beta.1") @@ -40,12 +40,12 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical bare package name for stable fallback", async () => { //#given - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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..b8e858ba1 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -57,12 +57,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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 +92,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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 +131,12 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") // given mock npm fetch - globalThis.fetch = mock(() => + globalThis.fetch = testCoerce(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..f859923d3 100644 --- a/src/cli/run/completion-continuation.test.ts +++ b/src/cli/run/completion-continuation.test.ts @@ -26,7 +26,7 @@ function createTempDir(): string { function createMockContext(directory: string): RunContext { return { - client: { + client: testCoerce({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock(() => Promise.resolve({ data: [] })), @@ -39,7 +39,7 @@ function createMockContext(directory: string): RunContext { })), messages: mock(async () => ({ data: [] })), }, - } as unknown as RunContext["client"], + }), sessionID: "test-session", directory, abortController: new AbortController(), @@ -155,17 +155,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 = testCoerce(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 = testCoerce(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 +187,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 = testCoerce(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 = testCoerce(mock(async () => ({ data: [] }))) const { checkCompletionConditions } = await import("./completion") @@ -218,17 +218,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 = testCoerce(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 = testCoerce(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 +253,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 = testCoerce(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 = testCoerce(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 +288,14 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_appended_descendant" - ctx.client.session.get = mock(async () => { + ctx.client.session.get = testCoerce(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 = testCoerce(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 +317,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 = testCoerce(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 +347,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 = testCoerce(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 +374,9 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_unknown_child" - ctx.client.session.get = mock(async () => { + ctx.client.session.get = testCoerce(mock(async () => { throw new Error("lineage unavailable") - }) as unknown as RunContext["client"]["session"]["get"] + })) const { checkCompletionConditions } = await import("./completion") @@ -401,17 +401,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 = testCoerce(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 = testCoerce(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 +437,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 = testCoerce(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 = testCoerce(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 +472,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 = testCoerce(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 = testCoerce(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 +486,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 +512,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 = testCoerce(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 = testCoerce(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..80db21e65 100644 --- a/src/cli/run/completion-verbose-logging.test.ts +++ b/src/cli/run/completion-verbose-logging.test.ts @@ -13,7 +13,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: testCoerce({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock((opts: { path: { id: string } }) => @@ -21,7 +21,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..56916b9a0 100644 --- a/src/cli/run/completion.test.ts +++ b/src/cli/run/completion.test.ts @@ -13,7 +13,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: testCoerce({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => @@ -21,7 +21,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..eae1911a4 100644 --- a/src/cli/run/event-handlers.test.ts +++ b/src/cli/run/event-handlers.test.ts @@ -23,7 +23,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with idle status - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, testCoerce(payload), state) //#then - state.mainSessionIdle === true expect(state.mainSessionIdle).toBe(true) @@ -44,7 +44,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with busy status - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, testCoerce(payload), state) //#then - state.mainSessionIdle === false expect(state.mainSessionIdle).toBe(false) @@ -65,7 +65,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with different session ID - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, testCoerce(payload), state) //#then - state.mainSessionIdle remains unchanged expect(state.mainSessionIdle).toBe(true) @@ -86,7 +86,7 @@ describe("handleSessionStatus", () => { } //#when - handleSessionStatus called with camelCase sessionId - handleSessionStatus(ctx, payload as any, state) + handleSessionStatus(ctx, testCoerce(payload), state) //#then - state.mainSessionIdle === true expect(state.mainSessionIdle).toBe(true) @@ -114,7 +114,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, testCoerce(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -142,7 +142,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, testCoerce(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(false) @@ -170,7 +170,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, testCoerce(payload), state) //#then expect(state.currentTool).toBe("read") @@ -200,7 +200,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, testCoerce(payload), state) //#then expect(state.currentTool).toBeNull() @@ -225,7 +225,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, payload as any, state) + handleMessagePartUpdated(ctx, testCoerce(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -243,7 +243,7 @@ describe("handleMessagePartUpdated", () => { handleMessageUpdated( ctx, - { + testCoerce({ type: "message.updated", properties: { info: { @@ -254,7 +254,7 @@ describe("handleMessagePartUpdated", () => { modelID: "claude-sonnet-4-6", }, }, - } as any, + }), state, ) state.messageStartedAtById["msg_1"] = 1000 @@ -262,7 +262,7 @@ describe("handleMessagePartUpdated", () => { // when handleMessagePartUpdated( ctx, - { + testCoerce({ type: "message.part.updated", properties: { part: { @@ -274,13 +274,13 @@ describe("handleMessagePartUpdated", () => { time: { end: 1 }, }, }, - } as any, + }), state, ) handleMessagePartUpdated( ctx, - { + testCoerce({ type: "message.part.updated", properties: { part: { @@ -292,7 +292,7 @@ describe("handleMessagePartUpdated", () => { time: { end: 2 }, }, }, - } as any, + }), state, ) @@ -323,7 +323,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, payload as any, state) + handleTuiToast(ctx, testCoerce(payload), state) //#then expect(state.mainSessionError).toBe(true) @@ -344,7 +344,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, payload as any, state) + handleTuiToast(ctx, testCoerce(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..19fb1c254 100644 --- a/src/cli/run/integration.test.ts +++ b/src/cli/run/integration.test.ts @@ -56,14 +56,14 @@ function createMockWriteStream(): MockWriteStream { const createMockClient = ( getResult?: { error?: unknown; data?: { id: string } } -): OpencodeClient => ({ +): OpencodeClient => (testCoerce({ 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 +78,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: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) // when @@ -103,8 +103,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: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) manager.redirectToStderr() @@ -272,8 +272,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: testCoerce(mockStdout), + stderr: testCoerce(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..58bf96c05 100644 --- a/src/cli/run/json-output.test.ts +++ b/src/cli/run/json-output.test.ts @@ -31,8 +31,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: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) manager.redirectToStderr() @@ -49,8 +49,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: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) manager.redirectToStderr() @@ -75,8 +75,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) // when @@ -98,8 +98,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) // when @@ -126,8 +126,8 @@ describe("createJsonOutputManager", () => { summary: "Test", } const manager = createJsonOutputManager({ - stdout: mockStdout as unknown as NodeJS.WriteStream, - stderr: mockStderr as unknown as NodeJS.WriteStream, + stdout: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) manager.redirectToStderr() @@ -148,8 +148,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: testCoerce(mockStdout), + stderr: testCoerce(mockStderr), }) // when diff --git a/src/cli/run/poll-for-completion.test.ts b/src/cli/run/poll-for-completion.test.ts index 670c6ba05..4d6529373 100644 --- a/src/cli/run/poll-for-completion.test.ts +++ b/src/cli/run/poll-for-completion.test.ts @@ -15,7 +15,7 @@ const createMockContext = (overrides: { } = overrides return { - client: { + client: testCoerce({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => @@ -23,7 +23,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 +124,7 @@ describe("pollForCompletion", () => { let todoCallCount = 0 let busyInserted = false - ;(ctx.client.session as any).todo = mock(async () => { + ;(testCoerce(ctx.client.session)).todo = mock(async () => { todoCallCount++ if (todoCallCount === 1 && !busyInserted) { busyInserted = true @@ -133,10 +133,10 @@ describe("pollForCompletion", () => { } return { data: [] } }) - ;(ctx.client.session as any).children = mock(() => + ;(testCoerce(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(ctx.client.session as any).status = mock(() => + ;(testCoerce(ctx.client.session)).status = mock(() => Promise.resolve({ data: {} }) ) @@ -322,17 +322,17 @@ describe("pollForCompletion", () => { const abortController = new AbortController() let pollTick = 0 - ;(ctx.client.session as any).todo = mock(async () => { + ;(testCoerce(ctx.client.session)).todo = mock(async () => { pollTick++ if (pollTick === 2) { eventState.currentTool = "task" } return { data: [] } }) - ;(ctx.client.session as any).children = mock(() => + ;(testCoerce(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(ctx.client.session as any).status = mock(() => + ;(testCoerce(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..f56a17280 100644 --- a/src/cli/run/session-resolver.test.ts +++ b/src/cli/run/session-resolver.test.ts @@ -10,7 +10,7 @@ const createMockClient = (overrides: { } = {}): OpencodeClient => { const { getResult, createResults = [] } = overrides let createCallIndex = 0 - return { + return testCoerce({ session: { get: mock((opts: { path: { id: string } }) => Promise.resolve(getResult ?? { data: { id: opts.path.id } }) @@ -22,7 +22,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..988eb7374 100644 --- a/src/cli/run/timestamp-output.test.ts +++ b/src/cli/run/timestamp-output.test.ts @@ -87,7 +87,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(testCoerce(stdout)) // when controller.enable() @@ -101,7 +101,7 @@ describe("createTimestampedStdoutController", () => { it("restores original write function", () => { // given const stdout = createMockWriteStream() - const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + const controller = createTimestampedStdoutController(testCoerce(stdout)) controller.enable() // when @@ -118,7 +118,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(testCoerce(stdout)) // when controller.enable() From 11b363849366db64a5f8a4faced58bc6ddf68b85 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:21:46 +0900 Subject: [PATCH 23/35] test(features): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...kground-task-notification-template.test.ts | 8 ++--- .../compaction-aware-message-resolver.test.ts | 2 +- .../manager-circuit-breaker.test.ts | 8 ++--- .../manager-session-permission.test.ts | 4 +-- .../session-existence.test.ts | 4 +-- .../subagent-spawn-limits.test.ts | 32 +++++++++---------- .../context-injector/injector.test.ts | 8 ++--- src/features/context-injector/injector.ts | 15 ++++++--- .../hook-message-injector/injector.test.ts | 32 +++++++++---------- .../skill-mcp-manager/manager.test.ts | 10 +++--- .../task-toast-manager/manager.test.ts | 18 +++++------ .../cleanup-team-run-resources.test.ts | 4 +-- .../tmux-subagent/polling-manager.test.ts | 22 ++++++------- 13 files changed, 87 insertions(+), 80 deletions(-) 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..80cbb93f5 100644 --- a/src/features/background-agent/background-task-notification-template.test.ts +++ b/src/features/background-agent/background-task-notification-template.test.ts @@ -134,7 +134,7 @@ Use \`background_output(task_id="")\` to retrieve each result. const notification = buildBackgroundTaskNotificationText({ task: { id: "bg_abc123", - description: undefined as unknown as string, + description: testCoerce(undefined), status: "completed", }, duration: "5s", @@ -142,8 +142,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: testCoerce(undefined), status: "completed" }, + { id: "bg_def456", description: testCoerce(undefined), status: "completed" }, ], }) @@ -230,7 +230,7 @@ Use \`background_output(task_id="")\` to retrieve each result. const notification = buildBackgroundTaskNotificationText({ task: { id: "bg_xyz789", - description: undefined as unknown as string, + description: testCoerce(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..191b5152f 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.test.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.test.ts @@ -49,7 +49,7 @@ describe("isCompactionAgent", () => { test("returns false for null", () => { // when - const result = isCompactionAgent(null as unknown as string) + const result = isCompactionAgent(testCoerce(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..0e5308400 100644 --- a/src/features/background-agent/manager-circuit-breaker.test.ts +++ b/src/features/background-agent/manager-circuit-breaker.test.ts @@ -16,12 +16,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: testCoerce({ client, directory: tmpdir() }), config: config }) + const testManager = testCoerce<{ enqueueNotificationForParent: (sessionId: string, fn: () => Promise) => Promise notifyParentSession: (task: BackgroundTask) => Promise tasks: Map - } + }>(manager) testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => { await fn() @@ -32,7 +32,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager { } function getTaskMap(manager: BackgroundManager): Map { - return (manager as unknown as { tasks: Map }).tasks + return (testCoerce<{ 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..29f68ba20 100644 --- a/src/features/background-agent/manager-session-permission.test.ts +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -21,7 +21,7 @@ describe("BackgroundManager session permission", () => { }, } const directory = tmpdir() - const manager = new BackgroundManager({ pluginContext: { client, directory } as unknown as PluginInput }) + const manager = new BackgroundManager({ pluginContext: testCoerce({ client, directory }) }) // when await manager.launch({ @@ -62,7 +62,7 @@ describe("BackgroundManager session permission", () => { abort: async () => ({}), }, } - const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput }) + const manager = new BackgroundManager({ pluginContext: testCoerce({ client, directory: tmpdir() }) }) // when await manager.launch({ diff --git a/src/features/background-agent/session-existence.test.ts b/src/features/background-agent/session-existence.test.ts index 9b59a4816..09eb7eeee 100644 --- a/src/features/background-agent/session-existence.test.ts +++ b/src/features/background-agent/session-existence.test.ts @@ -7,11 +7,11 @@ 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 = testCoerce({ session: { get, }, - } as unknown as OpencodeClient + }) // when const result = await verifySessionExists(client, "session-123", "/project/root") diff --git a/src/features/background-agent/subagent-spawn-limits.test.ts b/src/features/background-agent/subagent-spawn-limits.test.ts index e3094c551..c43bd8727 100644 --- a/src/features/background-agent/subagent-spawn-limits.test.ts +++ b/src/features/background-agent/subagent-spawn-limits.test.ts @@ -20,14 +20,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(testCoerce((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 +50,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(testCoerce((async () => ({ error: "lookup failed", data: undefined, - })) as unknown as OpencodeClient["session"]["get"]) + })))) // when const result = resolveSubagentSpawnContext(client, "parent-session") @@ -66,9 +66,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(testCoerce((async () => ({ data: undefined, - })) as unknown as OpencodeClient["session"]["get"]) + })))) // when const result = resolveSubagentSpawnContext(client, "parent-session") @@ -81,12 +81,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(testCoerce((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 +99,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-1 child reports childDepth 2", async () => { // given - child -> root chain - const client = createMockClient((async (opts) => { + const client = createMockClient(testCoerce((async (opts) => { if (opts.path.id === "child-1") { return { data: { id: "child-1", parentID: "root-session" } } } @@ -107,7 +107,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 +120,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-2 grandchild reports childDepth 3", async () => { // given - grandchild -> child -> root chain - const client = createMockClient((async (opts) => { + const client = createMockClient(testCoerce((async (opts) => { const sessions: Record = { "grandchild": { id: "grandchild", parentID: "child" }, "child": { id: "child", parentID: "root" }, @@ -129,7 +129,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 +153,11 @@ describe("resolveSubagentSpawnContext", () => { } } - const client = createMockClient((async (opts) => { + const client = createMockClient(testCoerce((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 +170,7 @@ describe("resolveSubagentSpawnContext", () => { test("detects parent cycle and throws", async () => { // given - A -> B -> A (cycle) - const client = createMockClient((async (opts) => { + const client = createMockClient(testCoerce((async (opts) => { const sessions: Record = { "session-a": { id: "session-a", parentID: "session-b" }, "session-b": { id: "session-b", parentID: "session-a" }, @@ -178,7 +178,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/context-injector/injector.test.ts b/src/features/context-injector/injector.test.ts index 09de376fe..4a4f1d0ec 100644 --- a/src/features/context-injector/injector.test.ts +++ b/src/features/context-injector/injector.test.ts @@ -51,7 +51,7 @@ describe("createContextInjectorMessagesTransformHook", () => { createMockMessage("user", "Second message", sessionID), ] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const output = { messages } as any + const output = testCoerce({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -115,7 +115,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 = testCoerce({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -135,7 +135,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 = testCoerce({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) @@ -156,7 +156,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 = testCoerce({ 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..af63bffc6 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -73,7 +73,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(testCoerce(mockClient), "ses_123") expect(result).toEqual({ agent: "sisyphus", @@ -87,7 +87,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result).toEqual({ agent: "sisyphus", @@ -102,7 +102,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(testCoerce(mockClient), "ses_123") expect(result?.agent).toBe("new-agent") }) @@ -112,7 +112,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: { agent: "partial-agent" } }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result?.agent).toBe("partial-agent") }) @@ -123,7 +123,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { { info: {} }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBeNull() }) @@ -131,7 +131,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(testCoerce(mockClient), "ses_123") expect(result).toBeNull() }) @@ -145,7 +145,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, } - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBeNull() }) @@ -161,7 +161,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result?.tools).toEqual({ edit: true, write: false }) }) @@ -172,7 +172,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(testCoerce(mockClient), "ses_123") expect(result?.agent).toBe("newest-by-time") }) @@ -190,7 +190,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, ]) - const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") expect(result?.agent).toBe("sisyphus") }) @@ -252,7 +252,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: { agent: "second-agent" } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBe("first-agent") }) @@ -263,7 +263,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(testCoerce(mockClient), "ses_123") expect(result).toBe("earliest-agent") }) @@ -274,7 +274,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(testCoerce(mockClient), "ses_123") expect(result).toBe("sisyphus") }) @@ -285,7 +285,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: { agent: "first-real-agent" } }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBe("first-real-agent") }) @@ -296,7 +296,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { { info: {} }, ]) - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBeNull() }) @@ -310,7 +310,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { }, } - const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + const result = await findFirstMessageWithAgentFromSDK(testCoerce(mockClient), "ses_123") expect(result).toBeNull() }) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index f3ef6f51e..17a4cb2c6 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -634,7 +634,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(testCoerce(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -668,7 +668,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(testCoerce(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when / #then @@ -700,7 +700,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(testCoerce(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when / #then @@ -929,7 +929,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(testCoerce(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -962,7 +962,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(testCoerce(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..23a2f276c 100644 --- a/src/features/task-toast-manager/manager.test.ts +++ b/src/features/task-toast-manager/manager.test.ts @@ -20,15 +20,15 @@ describe("TaskToastManager", () => { showToast: mock(() => Promise.resolve()), }, } - mockConcurrencyManager = { + mockConcurrencyManager = testCoerce({ 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(testCoerce(mockClient), mockConcurrencyManager) }) afterEach(() => { @@ -108,14 +108,14 @@ describe("TaskToastManager", () => { test("should display concurrency limit info when available", () => { // given - a concurrency manager with known limit - const mockConcurrencyWithCounts = { + const mockConcurrencyWithCounts = testCoerce({ 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(testCoerce(mockClient), mockConcurrencyWithCounts) // when - a task is added managerWithConcurrency.addTask({ @@ -357,11 +357,11 @@ describe("TaskToastManager", () => { test("should show model name in queued tasks too", () => { // given - a concurrency manager that limits to 1 - const limitedConcurrency = { + const limitedConcurrency = testCoerce({ 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(testCoerce(mockClient), limitedConcurrency) limitedManager.addTask({ id: "task_running", 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..af916c6b3 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 @@ -41,9 +41,9 @@ function createRuntimeState(teamRunId: string): RuntimeState { } function createStubBgMgr(): BackgroundManager { - return { + return testCoerce({ cancelTask: async () => undefined, - } as unknown as BackgroundManager + }) } describe("cleanupTeamRunResources", () => { diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 38b32797f..87b5b5297 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -39,15 +39,15 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + testCoerce(client), sessions, async () => {}, ) //#when - const firstPoll = (manager as unknown as { pollSessions: () => Promise }).pollSessions() + const firstPoll = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions() await Promise.resolve() - const secondPoll = (manager as unknown as { pollSessions: () => Promise }).pollSessions() + const secondPoll = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions() releaseStatus?.() await Promise.all([firstPoll, secondPoll]) @@ -85,7 +85,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + testCoerce(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -98,7 +98,7 @@ describe("TmuxPollingManager overlap", () => { }) //#when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) await pollSessions.call(manager) await pollSessions.call(manager) @@ -132,7 +132,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + testCoerce(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -140,7 +140,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -171,7 +171,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + testCoerce(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -179,7 +179,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -222,13 +222,13 @@ describe("TmuxPollingManager overlap", () => { } manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + testCoerce(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) }, ) - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions // when await pollSessions.call(manager) From 23211159c91a9064f630766c7c89079b85ad2717 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:22:05 +0900 Subject: [PATCH 24/35] test(hooks): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../executor.test.ts | 6 +- .../recovery-deduplication.test.ts | 2 +- .../summarize-retry-strategy.test.ts | 6 +- src/hooks/atlas/background-task-retry.test.ts | 102 +++++++++--------- .../boulder-continuation-injector.test.ts | 28 ++--- .../atlas/idle-event-complete-boulder.test.ts | 4 +- src/hooks/atlas/idle-event-lineage.test.ts | 4 +- .../idle-event-persisted-lineage.test.ts | 8 +- src/hooks/atlas/idle-event.test.ts | 4 +- src/hooks/atlas/recent-model-resolver.test.ts | 4 +- ...ol-execute-after-background-launch.test.ts | 24 ++--- .../checker/cached-version.test.ts | 2 +- .../category-skill-reminder/index.test.ts | 4 +- .../execute-http-hook-security.test.ts | 2 +- .../execute-http-hook.test.ts | 14 +-- .../tool-input-cache.test.ts | 6 +- src/hooks/comment-checker/cli.test.ts | 4 +- .../comment-checker/pending-calls.test.ts | 22 ++-- src/hooks/edit-error-recovery/index.test.ts | 4 +- .../keyword-detector/hook-ralph-loop.test.ts | 4 +- .../hyperplan-ultrawork.test.ts | 4 +- src/hooks/keyword-detector/index.test.ts | 8 +- .../ultrawork-edge-trigger.test.ts | 4 +- .../ultrawork-runtime-variant.test.ts | 4 +- src/hooks/model-fallback/hook.test.ts | 40 +++---- src/hooks/no-hephaestus-non-gpt/index.test.ts | 20 ++-- src/hooks/no-sisyphus-gpt/index.test.ts | 4 +- src/hooks/question-label-truncator/hook.ts | 10 +- .../question-label-truncator/index.test.ts | 20 ++-- .../runtime-fallback/fallback-models.test.ts | 12 +-- src/hooks/runtime-fallback/index.test.ts | 4 +- src/hooks/session-notification-sender.test.ts | 48 ++++----- .../recover-tool-result-missing.ts | 10 +- .../storage/readers-from-sdk.test.ts | 4 +- src/hooks/start-work/index.test.ts | 22 ++-- .../stop-continuation-guard/index.test.ts | 4 +- src/hooks/task-resume-info/index.test.ts | 2 +- 37 files changed, 242 insertions(+), 232 deletions(-) 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..753abe388 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -37,7 +37,7 @@ function createFakeTimeouts(): FakeTimeouts { callback, args, }) - return id as unknown as ReturnType + return testCoerce>(id) }) as typeof setTimeout globalThis.clearTimeout = ((id?: number) => { @@ -243,7 +243,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 = (testCoerce(mockClient.tui.showToast)).mock.calls const blockedToast = toastCalls.find( (call: any) => call[0]?.body?.title === "Compact In Progress", ) @@ -276,7 +276,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 = (testCoerce(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..184691e71 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 @@ -20,7 +20,7 @@ function createImmediateTimeouts(): () => void { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { callback(...args) - return 0 as unknown as ReturnType + return testCoerce>(0) }) as typeof setTimeout globalThis.clearTimeout = ((_: ReturnType) => {}) as typeof clearTimeout 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..c7091b151 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 @@ -95,7 +95,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 = testCoerce>(timeoutCalls.length + 1) timeoutCalls.push({ handle, delay: delay ?? 0 }) return handle }) as typeof setTimeout @@ -132,7 +132,7 @@ describe("runSummarizeRetryStrategy", () => { let scheduledCallback: (() => void) | undefined globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => { scheduledCallback = () => callback() - return 1 as unknown as ReturnType + return testCoerce>(1) }) as typeof setTimeout autoCompactState.pendingCompact.add(sessionID) @@ -176,7 +176,7 @@ describe("runSummarizeRetryStrategy", () => { autoCompactState.emptyContentAttemptBySession.set(sessionID, 3) autoCompactState.retryTimerBySession.set( sessionID, - 1 as unknown as ReturnType, + testCoerce>(1), ) //#when diff --git a/src/hooks/atlas/background-task-retry.test.ts b/src/hooks/atlas/background-task-retry.test.ts index e8a9cded6..1a050b0ed 100644 --- a/src/hooks/atlas/background-task-retry.test.ts +++ b/src/hooks/atlas/background-task-retry.test.ts @@ -79,7 +79,7 @@ describe("atlas background task retry", () => { callback: () => (callback as LongTimerCallback)(...args), cleared: false, }) - return id as unknown as ReturnType + return testCoerce>(id) } return originalSetTimeout(callback, delay, ...args) @@ -120,7 +120,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -128,13 +128,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: testCoerce[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -161,7 +161,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -169,13 +169,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: testCoerce[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -204,7 +204,7 @@ describe("atlas background task retry", () => { let remainingRunningRetries = 2 const promptMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -212,9 +212,11 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: testCoerce[1]>["backgroundManager"] & { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> + }>({ getTasksByParentSession: () => { if (remainingRunningRetries > 0) { remainingRunningRetries -= 1 @@ -223,9 +225,7 @@ describe("atlas background task retry", () => { return [] }, - } as unknown as NonNullable[1]>["backgroundManager"] & { - getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }), }) // when @@ -258,7 +258,7 @@ describe("atlas background task retry", () => { const promptAsyncMock = mock(async () => ({})) let backgroundCheckCount = 0 - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -266,9 +266,11 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: testCoerce[1]>["backgroundManager"] & { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> + }>({ getTasksByParentSession: () => { backgroundCheckCount += 1 if (backgroundCheckCount === 1) { @@ -281,9 +283,7 @@ describe("atlas background task retry", () => { return [] }, - } as unknown as NonNullable[1]>["backgroundManager"] & { - getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }), }) // when @@ -313,7 +313,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -321,13 +321,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: testCoerce[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + }), }) // when @@ -366,7 +366,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true let descendantAgent = "atlas" const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -384,18 +384,18 @@ describe("atlas background task retry", () => { }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { + backgroundManager: testCoerce[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 +424,7 @@ describe("atlas background task retry", () => { const deferredPrompt = createDeferred<{}>() const promptAsyncMock = mock(() => deferredPrompt.promise) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -432,7 +432,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 +462,7 @@ describe("atlas background task retry", () => { promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -470,13 +470,13 @@ describe("atlas background task retry", () => { messages: async () => ({ data: [] }), }, }, - } as unknown as PluginInput, { + }), { directory: testDir, - backgroundManager: { - getTasksByParentSession: () => [], - } as unknown as NonNullable[1]>["backgroundManager"] & { + backgroundManager: testCoerce[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> - }, + }>({ + getTasksByParentSession: () => [], + }), }) // when @@ -515,7 +515,7 @@ describe("atlas background task retry", () => { }) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce({ directory: testDir, client: { session: { @@ -523,13 +523,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: testCoerce[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..6aae87713 100644 --- a/src/hooks/atlas/boulder-continuation-injector.test.ts +++ b/src/hooks/atlas/boulder-continuation-injector.test.ts @@ -20,7 +20,7 @@ describe("injectBoulderContinuation", () => { const promptAsyncMock = mock(async (_request: unknown) => undefined) const messagesMock = mock(async () => ({ data: [] })) - const ctx = { + const ctx = testCoerce({ directory: "/tmp", client: { session: { @@ -28,7 +28,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -60,7 +60,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 } - const ctx = { + const ctx = testCoerce({ directory: "/tmp", client: { session: { @@ -68,7 +68,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -78,9 +78,9 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: { + backgroundManager: testCoerce[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "running" }], - } as unknown as Parameters[0]["backgroundManager"], + }), sessionState, }) @@ -98,7 +98,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 } - const ctx = { + const ctx = testCoerce({ directory: "/tmp", client: { session: { @@ -106,7 +106,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -116,9 +116,9 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: { + backgroundManager: testCoerce[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "pending" }], - } as unknown as Parameters[0]["backgroundManager"], + }), sessionState, }) @@ -134,7 +134,7 @@ describe("injectBoulderContinuation", () => { const promptAsyncMock = mock(async (_request: unknown) => undefined) const messagesMock = mock(async () => ({ data: [] })) - const ctx = { + const ctx = testCoerce({ directory: "/tmp", client: { session: { @@ -142,7 +142,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ @@ -189,7 +189,7 @@ describe("injectBoulderContinuation", () => { }], })) - const ctx = { + const ctx = testCoerce({ directory: "/tmp", client: { session: { @@ -197,7 +197,7 @@ describe("injectBoulderContinuation", () => { promptAsync: promptAsyncMock, }, }, - } as unknown as PluginInput + }) // when const result = await injectBoulderContinuation({ diff --git a/src/hooks/atlas/idle-event-complete-boulder.test.ts b/src/hooks/atlas/idle-event-complete-boulder.test.ts index a03b27fe7..85577ab57 100644 --- a/src/hooks/atlas/idle-event-complete-boulder.test.ts +++ b/src/hooks/atlas/idle-event-complete-boulder.test.ts @@ -49,7 +49,7 @@ describe("atlas hook idle-event complete boulder", () => { }, }) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce[0]>({ directory: testDirectory, client: { session: { @@ -59,7 +59,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..77e781376 100644 --- a/src/hooks/atlas/idle-event-lineage.test.ts +++ b/src/hooks/atlas/idle-event-lineage.test.ts @@ -32,7 +32,7 @@ describe("atlas hook idle-event session lineage", () => { } function createHook(parentSessionIDs?: Record) { - return createAtlasHook({ + return createAtlasHook(testCoerce[0]>({ directory: testDirectory, client: { session: { @@ -52,7 +52,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..16841ccd3 100644 --- a/src/hooks/atlas/idle-event-persisted-lineage.test.ts +++ b/src/hooks/atlas/idle-event-persisted-lineage.test.ts @@ -58,7 +58,7 @@ describe("atlas hook idle-event persisted lineage", () => { parentSessionIDs?: Record, messagesBySession?: Record>, ) { - return createAtlasHook({ + return createAtlasHook(testCoerce[0]>({ directory: testDirectory, client: { session: { @@ -79,7 +79,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }, }, - } as unknown as Parameters[0]) + })) } beforeEach(() => { @@ -173,7 +173,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }) - const hook = createAtlasHook({ + const hook = createAtlasHook(testCoerce[0]>({ directory: testDirectory, client: { session: { @@ -193,7 +193,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..57a3be1d8 100644 --- a/src/hooks/atlas/idle-event.test.ts +++ b/src/hooks/atlas/idle-event.test.ts @@ -76,14 +76,14 @@ describe("handleAtlasSessionIdle completion nudge", () => { return { data: {} } }) - const ctx = { + const ctx = testCoerce({ 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/recent-model-resolver.test.ts b/src/hooks/atlas/recent-model-resolver.test.ts index 81db7dbe4..9da710cbb 100644 --- a/src/hooks/atlas/recent-model-resolver.test.ts +++ b/src/hooks/atlas/recent-model-resolver.test.ts @@ -5,7 +5,7 @@ import { resolveRecentPromptContextForSession } from "./recent-model-resolver" describe("resolveRecentPromptContextForSession", () => { test("uses message time.created rather than SDK array order for recent prompt context", async () => { // given - const ctx = { + const ctx = testCoerce({ client: { session: { messages: mock(async () => ({ @@ -32,7 +32,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..6fccf6a89 100644 --- a/src/hooks/atlas/tool-execute-after-background-launch.test.ts +++ b/src/hooks/atlas/tool-execute-after-background-launch.test.ts @@ -80,11 +80,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => { function createHandler(parentSessionIDs?: Record) { const project = createProject() - const client = { + const client = testCoerce({ 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 +141,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 = testCoerce({ 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 +215,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 = testCoerce({ session: { get: async () => createSessionGetResult(undefined), }, - } as unknown as PluginInput["client"] + }) spyOn(client.session, "get").mockImplementation((input) => { if (input?.path?.id === childSessionID) { @@ -288,11 +288,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 = testCoerce({ 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 +358,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 = testCoerce({ 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 +431,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 = testCoerce({ 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-update-checker/checker/cached-version.test.ts b/src/hooks/auto-update-checker/checker/cached-version.test.ts index 352de6d19..b686883db 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 = (testCoerce>(current))[prop] if (typeof value === "function") { return (value as (...args: unknown[]) => unknown).bind(current) } diff --git a/src/hooks/category-skill-reminder/index.test.ts b/src/hooks/category-skill-reminder/index.test.ts index 08d6118b8..83e1136da 100644 --- a/src/hooks/category-skill-reminder/index.test.ts +++ b/src/hooks/category-skill-reminder/index.test.ts @@ -21,13 +21,13 @@ describe("category-skill-reminder hook", () => { }) function createMockPluginInput() { - return { + return testCoerce({ client: { tui: { showToast: async () => {}, }, }, - } as any + }) } function createHook(availableSkills: AvailableSkill[] = []) { diff --git a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts index 65ee6f37d..e9ee0db93 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts @@ -31,7 +31,7 @@ describe("executeHttpHook TLS security", () => { let logCalls: Array<{ message: string; data?: unknown }> beforeEach(() => { - globalThis.fetch = mockFetch as unknown as typeof fetch + globalThis.fetch = testCoerce(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..4dfe5cb3d 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -9,7 +9,7 @@ const originalFetch = globalThis.fetch describe("executeHttpHook", () => { beforeEach(() => { - globalThis.fetch = mockFetch as unknown as typeof fetch + globalThis.fetch = testCoerce(mockFetch) mockFetch.mockReset() mockFetch.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) @@ -33,7 +33,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] = testCoerce<[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 +44,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Content-Type"]).toBe("application/json") }) @@ -72,7 +72,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -88,7 +88,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -104,7 +104,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer ") }) @@ -121,7 +121,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const [, options] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) expect(options.signal).toBeDefined() }) }) 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..345916ada 100644 --- a/src/hooks/claude-code-hooks/tool-input-cache.test.ts +++ b/src/hooks/claude-code-hooks/tool-input-cache.test.ts @@ -33,11 +33,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 = testCoerce>({ 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 = testCoerce(setIntervalMock) + globalThis.clearInterval = testCoerce(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..c8c736cb4 100644 --- a/src/hooks/comment-checker/cli.test.ts +++ b/src/hooks/comment-checker/cli.test.ts @@ -74,7 +74,7 @@ done const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return testCoerce>(0) }) as typeof setTimeout try { @@ -102,7 +102,7 @@ done const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return testCoerce>(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..d703820fd 100644 --- a/src/hooks/comment-checker/pending-calls.test.ts +++ b/src/hooks/comment-checker/pending-calls.test.ts @@ -7,18 +7,18 @@ describe("pending-calls cleanup interval", () => { const setIntervalCalls: number[] = [] let unrefCalled = 0 - globalThis.setInterval = (( + globalThis.setInterval = testCoerce((( _handler: TimerHandler, timeout?: number, - ..._args: any[] + ..._args: unknown[] ) => { setIntervalCalls.push(timeout as number) - return { + return testCoerce>({ 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 +43,20 @@ describe("pending-calls cleanup interval", () => { let intervalHandle: ReturnType | undefined let clearCalls = 0 - globalThis.setInterval = (( + globalThis.setInterval = testCoerce((( _handler: TimerHandler, _timeout?: number, - ..._args: any[] + ..._args: unknown[] ) => { - intervalHandle = { unref: () => {} } as unknown as ReturnType + intervalHandle = testCoerce>({ unref: () => {} }) return intervalHandle - }) as unknown as typeof setInterval + })) - globalThis.clearInterval = ((handle?: ReturnType) => { + globalThis.clearInterval = testCoerce(((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/edit-error-recovery/index.test.ts b/src/hooks/edit-error-recovery/index.test.ts index ab8627056..48bfca516 100644 --- a/src/hooks/edit-error-recovery/index.test.ts +++ b/src/hooks/edit-error-recovery/index.test.ts @@ -5,7 +5,7 @@ describe("createEditErrorRecoveryHook", () => { let hook: ReturnType beforeEach(() => { - hook = createEditErrorRecoveryHook({} as any) + hook = createEditErrorRecoveryHook(testCoerce({})) }) describe("tool.execute.after", () => { @@ -108,7 +108,7 @@ describe("createEditErrorRecoveryHook", () => { const input = createInput("Edit") const output = { title: "Edit", - output: undefined as unknown as string, + output: testCoerce(undefined), metadata: {}, } diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts index 0cb5972d8..09d3d21e7 100644 --- a/src/hooks/keyword-detector/hook-ralph-loop.test.ts +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -11,13 +11,13 @@ type StartLoopCall = { type CancelLoopCall = { sessionID: string } function createMockPluginInput() { - return { + return testCoerce({ 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..06f57d40b 100644 --- a/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts +++ b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts @@ -22,7 +22,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { + return testCoerce({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -30,7 +30,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..12c0a9fa8 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -881,13 +881,13 @@ describe("keyword-detector team mode", () => { }) function createMockPluginInput() { - return { + return testCoerce({ client: { tui: { showToast: async () => {}, }, }, - } as unknown as PluginInput + }) } test("should inject team-mode message when user types 'team mode'", async () => { @@ -1063,7 +1063,7 @@ describe("keyword-detector disabled_keywords config", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { + return testCoerce({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -1071,7 +1071,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..abbbfb51b 100644 --- a/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts +++ b/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts @@ -11,7 +11,7 @@ type StartLoopCall = { } function createMockPluginInput(toastCalls: string[] = []) { - return { + return testCoerce({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { @@ -19,7 +19,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..8a83bf1a7 100644 --- a/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts +++ b/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts @@ -3,7 +3,7 @@ import { createKeywordDetectorHook } from "./index" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" function createMockPluginInput(toastMessages: string[]) { - return { + return testCoerce({ client: { tui: { showToast: async (opts: { body: { message: string } }) => { @@ -11,7 +11,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..47df5cfb6 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -86,12 +86,12 @@ describe("model fallback hook", () => { }) test("applies pending fallback on chat.message by overriding model", async () => { - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, ) => Promise - } + }>(modelFallback) const set = setPendingModelFallback( modelFallback, @@ -122,12 +122,12 @@ describe("model fallback hook", () => { }) test("preserves fallback progression across repeated session.error retries", async () => { - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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 +212,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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 +254,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_variant_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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 +299,12 @@ describe("model fallback hook", () => { clearPendingModelFallback(modelFallback, sessionID) readConnectedProvidersCacheMock.mockReturnValue(["provider-x"]) - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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 +355,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 = testCoerce<{ "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 +393,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_ghcp" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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 +434,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_google" clearPendingModelFallback(modelFallback, sessionID) - const hook = modelFallback as unknown as { + const hook = testCoerce<{ "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..350564afc 100644 --- a/src/hooks/no-hephaestus-non-gpt/index.test.ts +++ b/src/hooks/no-hephaestus-non-gpt/index.test.ts @@ -19,9 +19,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(testCoerce({ client: { tui: { showToast } }, - } as any) + })) const output1 = createOutput() const output2 = createOutput() @@ -54,9 +54,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(testCoerce({ client: { tui: { showToast } }, - } as any, { + }), { allowNonGptModel: true, }) @@ -83,9 +83,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(testCoerce({ client: { tui: { showToast } }, - } as any) + })) const output = createOutput() @@ -104,9 +104,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(testCoerce({ client: { tui: { showToast } }, - } as any) + })) const output = createOutput() @@ -127,9 +127,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(testCoerce({ 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..c507aa5d7 100644 --- a/src/hooks/no-sisyphus-gpt/index.test.ts +++ b/src/hooks/no-sisyphus-gpt/index.test.ts @@ -22,9 +22,9 @@ function createOutput(): HookOutput { } function createHookContext(showToast: (input: unknown) => Promise): PluginInput { - return { + return testCoerce({ client: { tui: { showToast } }, - } as unknown as PluginInput + }) } describe("no-sisyphus-gpt hook", () => { 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..fac49527d 100644 --- a/src/hooks/question-label-truncator/index.test.ts +++ b/src/hooks/question-label-truncator/index.test.ts @@ -23,10 +23,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(testCoerce(input), testCoerce(output)); // then - const truncatedLabel = (output.args as any).questions[0].options[0].label; + const truncatedLabel = (testCoerce(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 +50,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(testCoerce(input), testCoerce(output)); // then - const resultLabel = (output.args as any).questions[0].options[0].label; + const resultLabel = (testCoerce(output.args)).questions[0].options[0].label; expect(resultLabel).toBe(shortLabel); }); @@ -74,10 +74,10 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(testCoerce(input), testCoerce(output)); // then - const resultLabel = (output.args as any).questions[0].options[0].label; + const resultLabel = (testCoerce(output.args)).questions[0].options[0].label; expect(resultLabel).toBe(exactLabel); }); @@ -90,7 +90,7 @@ describe("createQuestionLabelTruncatorHook", () => { const originalArgs = { ...output.args }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(testCoerce(input), testCoerce(output)); // then expect(output.args).toEqual(originalArgs); @@ -120,11 +120,11 @@ describe("createQuestionLabelTruncatorHook", () => { }; // when - await hook["tool.execute.before"]?.(input as any, output as any); + await hook["tool.execute.before"]?.(testCoerce(input), testCoerce(output)); // then - const q1opts = (output.args as any).questions[0].options; - const q2opts = (output.args as any).questions[1].options; + const q1opts = (testCoerce(output.args)).questions[0].options; + const q2opts = (testCoerce(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/runtime-fallback/fallback-models.test.ts b/src/hooks/runtime-fallback/fallback-models.test.ts index ebfa8fbc9..401cf9dc0 100644 --- a/src/hooks/runtime-fallback/fallback-models.test.ts +++ b/src/hooks/runtime-fallback/fallback-models.test.ts @@ -12,13 +12,13 @@ describe("runtime-fallback fallback-models", () => { //#given const sessionID = "ses_runtime_fallback_category" SessionCategoryRegistry.register(sessionID, "quick") - const pluginConfig = { + const pluginConfig = testCoerce({ categories: { quick: { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], }, }, - } as any + }) //#when const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig) @@ -29,13 +29,13 @@ describe("runtime-fallback fallback-models", () => { test("uses agent-specific fallback_models when agent is resolved", () => { //#given - const pluginConfig = { + const pluginConfig = testCoerce({ 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 +46,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 = testCoerce({ agents: { sisyphus: { fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"], @@ -55,7 +55,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..a2ed188f4 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -41,7 +41,7 @@ describe("runtime-fallback", () => { abort?: (args: unknown) => Promise } }) { - return { + return testCoerce({ client: { tui: { showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => { @@ -59,7 +59,7 @@ describe("runtime-fallback", () => { }, }, directory: "/test/dir", - } as any + }) } function createMockConfig(overrides?: Partial): RuntimeFallbackConfig { diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts index 931443b4b..977a8e467 100644 --- a/src/hooks/session-notification-sender.test.ts +++ b/src/hooks/session-notification-sender.test.ts @@ -80,7 +80,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 = testCoerce({ $: (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 +95,7 @@ describe("session-notification-sender", () => { promise.nothrow = () => promise return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -107,7 +107,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +130,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -142,9 +142,9 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const calls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -157,9 +157,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 = testCoerce({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")), - } as unknown as PluginInput + }) const originalFactory = mockCtx.$ const trackingCalls: string[] = [] @@ -180,9 +180,9 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const trackingCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")), - } as unknown as PluginInput + }) const originalFactory = mockCtx.$ mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { @@ -200,9 +200,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 = testCoerce({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") @@ -213,7 +213,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux notify-send", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +236,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "linux", "Test", "Message") @@ -246,7 +246,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +269,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message") @@ -283,7 +283,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 = testCoerce({ $: (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 +306,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff") @@ -316,7 +316,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux paplay", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +339,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") @@ -351,7 +351,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getPaplayPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +374,7 @@ describe("session-notification-sender", () => { } return promise }, - } as unknown as PluginInput + }) await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") @@ -384,7 +384,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell sound", async () => { const quietCalls: string[] = [] - const mockCtx = { + const mockCtx = testCoerce({ $: (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 +407,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-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..fc3e944b4 100644 --- a/src/hooks/session-recovery/storage/readers-from-sdk.test.ts +++ b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts @@ -13,7 +13,7 @@ function createMockClient(handlers: { messages?: (sessionID: string) => unknown[] message?: (sessionID: string, messageID: string) => unknown }) { - return { + return testCoerce({ session: { messages: async (opts: { path: { id: string } }) => { if (handlers.messages) { @@ -28,7 +28,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..56afe2ccf 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -738,7 +738,7 @@ You are starting a Sisyphus work session. const promptAsyncMock = spyOn({ promptAsync: async (_request: unknown) => undefined, }, "promptAsync") - const ctx = { + const ctx = testCoerce[0]>({ directory: testDir, client: { session: { @@ -747,7 +747,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 +784,18 @@ You are starting a Sisyphus work session. promptAsync: async (_request: unknown) => undefined, }, "promptAsync") - globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => { + globalThis.setTimeout = testCoerce(((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 testCoerce>(id) } return originalSetTimeout(callback as Parameters[0], delay) - }) as unknown as typeof setTimeout + })) - globalThis.clearTimeout = ((id?: number | ReturnType) => { + globalThis.clearTimeout = testCoerce(((id?: number | ReturnType) => { if (typeof id === "number" && capturedTimers.has(id)) { capturedTimers.get(id)!.cleared = true capturedTimers.delete(id) @@ -803,11 +803,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 = testCoerce[0]>({ directory: testDir, client: { session: { @@ -816,13 +816,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: testCoerce[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/index.test.ts b/src/hooks/stop-continuation-guard/index.test.ts index 8fa0a11a7..ba8986ebe 100644 --- a/src/hooks/stop-continuation-guard/index.test.ts +++ b/src/hooks/stop-continuation-guard/index.test.ts @@ -31,14 +31,14 @@ describe("stop-continuation-guard", () => { }) function createMockPluginInput() { - return { + return testCoerce({ client: { tui: { showToast: async () => ({}), }, }, directory: createTempDir(), - } as unknown as PluginInput + }) } function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask { diff --git a/src/hooks/task-resume-info/index.test.ts b/src/hooks/task-resume-info/index.test.ts index 30708380b..c9262eb7f 100644 --- a/src/hooks/task-resume-info/index.test.ts +++ b/src/hooks/task-resume-info/index.test.ts @@ -19,7 +19,7 @@ describe("createTaskResumeInfoHook", () => { const input = createInput("task") const output = { title: "delegate_task", - output: undefined as unknown as string, + output: testCoerce(undefined), metadata: {}, } From 0787867384b94a1e591b112c4838b90c2293de94 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:22:18 +0900 Subject: [PATCH 25/35] test(ralph-loop): remove unsafe fixture assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...nput.ts => completion-promise-detector-test-input.test.ts} | 4 ++-- src/hooks/ralph-loop/completion-promise-detector.test.ts | 2 +- .../ralph-loop/completion-promise-session-negative.test.ts | 2 +- src/hooks/ralph-loop/reset-strategy-race-condition.test.ts | 4 ++-- src/hooks/ralph-loop/ulw-loop-verification.test.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/hooks/ralph-loop/{completion-promise-detector-test-input.ts => completion-promise-detector-test-input.test.ts} (75%) 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 75% 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..07b517667 100644 --- a/src/hooks/ralph-loop/completion-promise-detector-test-input.ts +++ b/src/hooks/ralph-loop/completion-promise-detector-test-input.test.ts @@ -16,8 +16,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 = testCoerce(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 d59151a7b..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", () => { 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/reset-strategy-race-condition.test.ts b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts index 15de66084..9713d45fd 100644 --- a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts +++ b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts @@ -44,7 +44,7 @@ describe("ralph-loop reset strategy race condition", () => { const selectSessionDeferred = createDeferred() const hook = createRalphLoopHook( - { + testCoerce[0]>({ directory: process.cwd(), client: { session: { @@ -86,7 +86,7 @@ describe("ralph-loop reset strategy race condition", () => { }, }, }, - } as unknown as Parameters[0], + }), { idleSettleMs: 0 }, ) diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index 54041f452..d0bc564c8 100644 --- a/src/hooks/ralph-loop/ulw-loop-verification.test.ts +++ b/src/hooks/ralph-loop/ulw-loop-verification.test.ts @@ -15,7 +15,7 @@ describe("ulw-loop verification", () => { let oracleTranscriptPath: string function createMockPluginInput() { - return { + return testCoerce[0]>({ client: { session: { promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { @@ -39,7 +39,7 @@ describe("ulw-loop verification", () => { }, }, directory: testDir, - } as unknown as Parameters[0] + }) } beforeEach(() => { From f05aeaa63a603fb5bc5ef0bac24310a687558ae1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:22:30 +0900 Subject: [PATCH 26/35] test(plugin): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin-handlers/config-handler.test.ts | 196 +++++++++--------- .../mcp-config-handler.test.ts | 14 +- src/plugin/chat-message.test.ts | 8 +- src/plugin/event.model-fallback-2941.test.ts | 22 +- .../event.model-fallback-pin-agent.test.ts | 12 +- src/plugin/event.model-fallback.test.ts | 56 ++--- .../fallback.cliproxyapi-matrix.test.ts | 26 +-- src/plugin/hooks/create-session-hooks.test.ts | 4 +- .../tool-execute-before.ulw-loop.test.ts | 22 +- src/plugin/ultrawork-model-override.test.ts | 12 +- 10 files changed, 186 insertions(+), 186 deletions(-) diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index d2b0b9ea9..b5608df66 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -46,36 +46,36 @@ beforeEach(async () => { mock.restore() configErrors.clearConfigLoadErrors() - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + spyOn(agents, testCoerce("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, testCoerce("loadUserCommands")).mockResolvedValue({}) + spyOn(commandLoader, testCoerce("loadProjectCommands")).mockResolvedValue({}) + spyOn(commandLoader, testCoerce("loadOpencodeGlobalCommands")).mockResolvedValue({}) + spyOn(commandLoader, testCoerce("loadOpencodeProjectCommands")).mockResolvedValue({}) - spyOn(builtinCommands, "loadBuiltinCommands" as any).mockReturnValue({}) + spyOn(builtinCommands, testCoerce("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, testCoerce("loadUserSkills")).mockResolvedValue({}) + spyOn(skillLoader, testCoerce("loadProjectSkills")).mockResolvedValue({}) + spyOn(skillLoader, testCoerce("loadOpencodeGlobalSkills")).mockResolvedValue({}) + spyOn(skillLoader, testCoerce("loadOpencodeProjectSkills")).mockResolvedValue({}) + spyOn(skillLoader, testCoerce("discoverUserClaudeSkills")).mockResolvedValue([]) + spyOn(skillLoader, testCoerce("discoverProjectClaudeSkills")).mockResolvedValue([]) + spyOn(skillLoader, testCoerce("discoverOpencodeGlobalSkills")).mockResolvedValue([]) + spyOn(skillLoader, testCoerce("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, testCoerce("loadUserAgents")).mockReturnValue({}) + spyOn(agentLoader, testCoerce("loadProjectAgents")).mockReturnValue({}) + spyOn(agentLoader, testCoerce("loadOpencodeGlobalAgents")).mockReturnValue({}) + spyOn(agentLoader, testCoerce("loadOpencodeProjectAgents")).mockReturnValue({}) - spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} }) + spyOn(mcpLoader, testCoerce("loadMcpConfigs")).mockResolvedValue({ servers: {} }) setAdditionalAllowedMcpEnvVarsSpy = spyOn(mcpLoader, "setAdditionalAllowedMcpEnvVars").mockImplementation(() => {}) - spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({ + spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockResolvedValue({ commands: {}, skills: {}, agents: {}, @@ -85,54 +85,54 @@ beforeEach(async () => { errors: [], }) - spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({}) + spyOn(mcpModule, testCoerce("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, testCoerce("log")).mockImplementation(() => {}) + spyOn(shared, testCoerce("fetchAvailableModels")).mockResolvedValue(new Set(["anthropic/claude-opus-4-7"])) + spyOn(shared, testCoerce("readConnectedProvidersCache")).mockReturnValue(null) - spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({ + spyOn(configDir, testCoerce("getOpenCodeConfigPaths")).mockReturnValue({ global: "/tmp/.config/opencode", project: "/tmp/.opencode", }) - spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record) => config) + spyOn(permissionCompat, testCoerce("migrateAgentConfig")).mockImplementation((config: Record) => config) - spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-7" }) + spyOn(modelResolver, testCoerce("resolveModelWithFallback")).mockReturnValue({ model: "anthropic/claude-opus-4-7" }) ;({ 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?.() + (testCoerce(agents.createBuiltinAgents))?.mockRestore?.() + ;(testCoerce(sisyphusJunior.createSisyphusJuniorAgentWithOverrides))?.mockRestore?.() + ;(testCoerce(commandLoader.loadUserCommands))?.mockRestore?.() + ;(testCoerce(commandLoader.loadProjectCommands))?.mockRestore?.() + ;(testCoerce(commandLoader.loadOpencodeGlobalCommands))?.mockRestore?.() + ;(testCoerce(commandLoader.loadOpencodeProjectCommands))?.mockRestore?.() + ;(testCoerce(builtinCommands.loadBuiltinCommands))?.mockRestore?.() + ;(testCoerce(skillLoader.loadUserSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.loadProjectSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.loadOpencodeGlobalSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.loadOpencodeProjectSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.discoverUserClaudeSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.discoverProjectClaudeSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.discoverOpencodeGlobalSkills))?.mockRestore?.() + ;(testCoerce(skillLoader.discoverOpencodeProjectSkills))?.mockRestore?.() + ;(testCoerce(agentLoader.loadUserAgents))?.mockRestore?.() + ;(testCoerce(agentLoader.loadProjectAgents))?.mockRestore?.() + ;(testCoerce(agentLoader.loadOpencodeGlobalAgents))?.mockRestore?.() + ;(testCoerce(agentLoader.loadOpencodeProjectAgents))?.mockRestore?.() + ;(testCoerce(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?.() + ;(testCoerce(pluginLoader.loadAllPluginComponents))?.mockRestore?.() + ;(testCoerce(mcpModule.createBuiltinMcps))?.mockRestore?.() + ;(testCoerce(shared.log))?.mockRestore?.() + ;(testCoerce(shared.fetchAvailableModels))?.mockRestore?.() + ;(testCoerce(shared.readConnectedProvidersCache))?.mockRestore?.() + ;(testCoerce(configDir.getOpenCodeConfigPaths))?.mockRestore?.() + ;(testCoerce(permissionCompat.migrateAgentConfig))?.mockRestore?.() + ;(testCoerce(modelResolver.resolveModelWithFallback))?.mockRestore?.() + ;(testCoerce(agentPriorityOrder.reorderAgentsByPriority))?.mockRestore?.() configErrors.clearConfigLoadErrors() mock.restore() }) @@ -230,10 +230,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 = testCoerce<{ 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 +275,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 = testCoerce<{ 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 = testCoerce(spyOn(agentPriorityOrder, "reorderAgentsByPriority")) const pluginConfig = createPluginConfig({ sisyphus_agent: { planner_enabled: true, @@ -321,9 +321,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 = testCoerce<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { prompt: "test", mode: "primary" }, hephaestus: { prompt: "test", mode: "primary" }, @@ -485,9 +485,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 = testCoerce<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -1054,7 +1054,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, testCoerce("resolveModelPipeline")).mockReturnValue({ model: "openai/gpt-5.4", provenance: "override", variant: "high", @@ -1109,7 +1109,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, testCoerce("resolveModelPipeline")).mockReturnValue({ model: "anthropic/claude-opus-4-7", provenance: "provider-fallback", variant: "max", @@ -1152,7 +1152,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, testCoerce("resolveModelPipeline")).mockReturnValue({ model: "anthropic/claude-opus-4-7", provenance: "provider-fallback", variant: "max", @@ -1229,8 +1229,8 @@ 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")) + ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockRejectedValue(new Error("crash")) const pluginConfig = createPluginConfig({}) const config: Record = { model: "anthropic/claude-opus-4-7", @@ -1255,8 +1255,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( + ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockImplementation( () => new Promise(() => {}) ) const pluginConfig = createPluginConfig({ @@ -1285,8 +1285,8 @@ 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")) + ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockRejectedValue(new Error("crash")) const pluginConfig = createPluginConfig({}) const config: Record = { model: "anthropic/claude-opus-4-7", @@ -1314,8 +1314,8 @@ 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({ + ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() + spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockResolvedValue({ commands: { "test-cmd": { description: "test", template: "test" } }, skills: {}, agents: {}, @@ -1351,16 +1351,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 = testCoerce<{ 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 { + ;(testCoerce<{ mockReturnValue: (value: Record) => void - }).mockReturnValue({ + }>(builtinCommands.loadBuiltinCommands)).mockReturnValue({ "start-work": { name: "start-work", description: "(builtin) Start work", @@ -1404,9 +1404,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 = testCoerce<{ mockResolvedValue: (value: Record) => void - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, @@ -1445,10 +1445,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 = testCoerce<{ 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 +1487,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 = testCoerce<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, }) @@ -1526,10 +1526,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 = testCoerce<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "without-env", mode: "primary" }, }) @@ -1563,10 +1563,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 = testCoerce<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } - } + }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" }, }) @@ -1600,14 +1600,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({ + ;(testCoerce(agentLoader.loadUserAgents)).mockReturnValue({ "my-custom-agent": { description: "(user) global version", mode: "subagent", prompt: "I am the global agent", }, }) - ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + ;(testCoerce(agentLoader.loadProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(project) project version", mode: "subagent", @@ -1640,14 +1640,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({ + ;(testCoerce(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(agentLoader.loadOpencodeProjectAgents as any).mockReturnValue({ + ;(testCoerce(agentLoader.loadOpencodeProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode-project) project version", mode: "subagent", @@ -1680,14 +1680,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({ + ;(testCoerce(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + ;(testCoerce(agentLoader.loadProjectAgents)).mockReturnValue({ "my-custom-agent": { description: "(project) project version", mode: "subagent", @@ -1720,7 +1720,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({ + ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockResolvedValue({ commands: {}, skills: {}, agents: { @@ -1735,7 +1735,7 @@ describe("Agent merge priority — project-local overrides global", () => { plugins: [], errors: [], }) - ;(agentLoader.loadUserAgents as any).mockReturnValue({ + ;(testCoerce(agentLoader.loadUserAgents)).mockReturnValue({ "my-custom-agent": { description: "(user) global version", mode: "subagent", diff --git a/src/plugin-handlers/mcp-config-handler.test.ts b/src/plugin-handlers/mcp-config-handler.test.ts index f9fc6472f..a22cc827f 100644 --- a/src/plugin-handlers/mcp-config-handler.test.ts +++ b/src/plugin-handlers/mcp-config-handler.test.ts @@ -11,17 +11,17 @@ let loadMcpConfigsSpy: ReturnType let createBuiltinMcpsSpy: ReturnType beforeEach(() => { - loadMcpConfigsSpy = spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ + loadMcpConfigsSpy = spyOn(mcpLoader, testCoerce("loadMcpConfigs")).mockResolvedValue({ servers: {}, }) - createBuiltinMcpsSpy = spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({}) - spyOn(shared, "log" as any).mockImplementation(() => {}) + createBuiltinMcpsSpy = spyOn(mcpModule, testCoerce("createBuiltinMcps")).mockReturnValue({}) + spyOn(shared, testCoerce("log")).mockImplementation(() => {}) }) afterEach(() => { loadMcpConfigsSpy.mockRestore() createBuiltinMcpsSpy.mockRestore() - ;(shared.log as any)?.mockRestore?.() + ;(testCoerce(shared.log))?.mockRestore?.() }) function createPluginConfig(overrides: Partial = {}): OhMyOpenCodeConfig { @@ -82,7 +82,7 @@ describe("applyMcpConfig", () => { }) const config: Record = { mcp: {} } - const pluginConfig = createPluginConfig({ disabled_mcps: ["playwright"] as any }) + const pluginConfig = createPluginConfig({ disabled_mcps: testCoerce(["playwright"]) }) //#when const { applyMcpConfig } = await import("./mcp-config-handler") @@ -107,7 +107,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: testCoerce(["firecrawl", "exa"]) }) //#when const { applyMcpConfig } = await import("./mcp-config-handler") @@ -145,7 +145,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: testCoerce(["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..ecde2d9d0 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -56,13 +56,13 @@ function createMockHandlerArgs(overrides?: { }) { const appliedSessions: string[] = [] return { - ctx: { client: { tui: { showToast: async () => {} } } } as any, - pluginConfig: (overrides?.pluginConfig ?? {}) as any, + ctx: testCoerce({ client: { tui: { showToast: async () => {} } } }), + pluginConfig: testCoerce((overrides?.pluginConfig ?? {})), firstMessageVariantGate: { shouldOverride: () => overrides?.shouldOverride ?? false, markApplied: (sessionID: string) => { appliedSessions.push(sessionID) }, }, - hooks: { + hooks: testCoerce({ stopContinuationGuard: null, backgroundNotificationHook: null, keywordDetector: null, @@ -70,7 +70,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..0904d6bbf 100644 --- a/src/plugin/event.model-fallback-2941.test.ts +++ b/src/plugin/event.model-fallback-2941.test.ts @@ -13,27 +13,27 @@ type EventHandlerInput = Parameters>[0] type ChatMessageHandlerArgs = Parameters[0] function asEventHandlerInput(input: EventInput): EventHandlerInput { - return input as unknown as EventHandlerInput + return testCoerce(input) } function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] { - return ctx as unknown as EventHandlerArgs["ctx"] + return testCoerce(ctx) } function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] { - return config as unknown as EventHandlerArgs["pluginConfig"] + return testCoerce(config) } function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] { - return ctx as unknown as ChatMessageHandlerArgs["ctx"] + return testCoerce(ctx) } function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] { - return config as unknown as ChatMessageHandlerArgs["pluginConfig"] + return testCoerce(config) } function createEventHandlerManagers(): EventHandlerArgs["managers"] { - return { + return testCoerce({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -41,17 +41,17 @@ function createEventHandlerManagers(): EventHandlerArgs["managers"] { skillMcpManager: { disconnectSession: async () => {}, }, - } as unknown as EventHandlerArgs["managers"] + }) } function createEventHandlerHooks(modelFallback: ReturnType): EventHandlerArgs["hooks"] { - return { + return testCoerce({ modelFallback, - } as unknown as EventHandlerArgs["hooks"] + }) } function createChatMessageHandlerHooks(modelFallback: ReturnType): ChatMessageHandlerArgs["hooks"] { - return { + return testCoerce({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -59,7 +59,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..059455655 100644 --- a/src/plugin/event.model-fallback-pin-agent.test.ts +++ b/src/plugin/event.model-fallback-pin-agent.test.ts @@ -50,16 +50,16 @@ describe("createEventHandler - model-fallback auto-continuation pins agent/model } const handler = createEventHandler({ - ctx: { + ctx: testCoerce({ directory: "/tmp", client: { session: sessionClient }, - } as any, - pluginConfig: (args?.pluginConfig ?? {}) as any, + }), + pluginConfig: testCoerce((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: testCoerce({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -67,8 +67,8 @@ describe("createEventHandler - model-fallback auto-continuation pins agent/model skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: args?.hooks ?? ({} as any), + }), + hooks: args?.hooks ?? (testCoerce({})), }) return { handler, promptAsyncBodies, promptBodies } diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 3ff82ae20..a31f4c2b3 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -22,7 +22,7 @@ describe("createEventHandler - model fallback", () => { const promptCalls: string[] = [] const handler = createEventHandler({ - ctx: { + ctx: testCoerce({ directory: "/tmp", client: { session: { @@ -36,13 +36,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: (args?.pluginConfig ?? {}) as any, + }), + pluginConfig: testCoerce((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: testCoerce({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -50,8 +50,8 @@ describe("createEventHandler - model fallback", () => { skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: args?.hooks ?? ({} as any), + }), + hooks: args?.hooks ?? (testCoerce({})), }) return { handler, abortCalls, promptCalls } @@ -148,19 +148,19 @@ describe("createEventHandler - model fallback", () => { const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: testCoerce({ client: { tui: { showToast: async () => ({}), }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: testCoerce({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: testCoerce({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -168,7 +168,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) await handler({ @@ -358,19 +358,19 @@ describe("createEventHandler - model fallback", () => { const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: testCoerce({ client: { tui: { showToast: async () => ({}), }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: testCoerce({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: testCoerce({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -378,7 +378,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) await handler({ @@ -449,7 +449,7 @@ describe("createEventHandler - model fallback", () => { setupConnectedProviderCacheMocks() const eventHandler = createEventHandler({ - ctx: { + ctx: testCoerce({ directory: "/tmp", client: { session: { @@ -463,13 +463,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: testCoerce({}), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: { + managers: testCoerce({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -477,14 +477,14 @@ describe("createEventHandler - model fallback", () => { skillMcpManager: { disconnectSession: async () => {}, }, - } as any, - hooks: { + }), + hooks: testCoerce({ modelFallback, - } as any, + }), }) const chatMessageHandler = createChatMessageHandler({ - ctx: { + ctx: testCoerce({ client: { tui: { showToast: async ({ body }: { body: { title?: string } }) => { @@ -493,13 +493,13 @@ describe("createEventHandler - model fallback", () => { }, }, }, - } as any, - pluginConfig: {} as any, + }), + pluginConfig: testCoerce({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: { + hooks: testCoerce({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -507,7 +507,7 @@ describe("createEventHandler - model fallback", () => { autoSlashCommand: null, startWork: null, ralphLoop: null, - } as any, + }), }) const triggerRetryCycle = async () => { diff --git a/src/plugin/fallback.cliproxyapi-matrix.test.ts b/src/plugin/fallback.cliproxyapi-matrix.test.ts index 3d1b5fd4c..1497ab285 100644 --- a/src/plugin/fallback.cliproxyapi-matrix.test.ts +++ b/src/plugin/fallback.cliproxyapi-matrix.test.ts @@ -18,42 +18,42 @@ type HarnessContext = EventHandlerArgs["ctx"] & RuntimeFallbackPluginInput type HarnessEventInput = Parameters["eventHandler"]>[0] function asHarnessEventInput(input: unknown): HarnessEventInput { - return input as unknown as HarnessEventInput + return testCoerce(input) } function asHarnessContext(ctx: unknown): HarnessContext { - return ctx as unknown as HarnessContext + return testCoerce(ctx) } function createEventHandlerManagers( overrides: Record = {}, ): EventHandlerArgs["managers"] { - return { + return testCoerce({ ...({} as EventHandlerArgs["managers"]), tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, ...overrides, - } as unknown as EventHandlerArgs["managers"] + }) } function createEventHandlerHooks( overrides: Record, ): EventHandlerArgs["hooks"] { - return { + return testCoerce({ ...({} as EventHandlerArgs["hooks"]), ...overrides, - } as unknown as EventHandlerArgs["hooks"] + }) } function createChatMessageHandlerHooks( overrides: Record, ): ChatMessageHandlerArgs["hooks"] { - return { + return testCoerce({ ...({} as ChatMessageHandlerArgs["hooks"]), ...overrides, - } as unknown as ChatMessageHandlerArgs["hooks"] + }) } const PRIMARY_MODEL = { @@ -87,7 +87,7 @@ let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined function createPluginConfig(mode: HarnessMode) { - return { + return testCoerce({ agents: { sisyphus: { fallback_models: CLIPROXYAPI_FALLBACKS, @@ -100,7 +100,7 @@ function createPluginConfig(mode: HarnessMode) { }, } : {}), - } as unknown as EventHandlerArgs["pluginConfig"] + }) } function createHarness(args: { @@ -187,14 +187,14 @@ function createHarness(args: { timeout_seconds: args.sessionTimeoutMs ? 30 : 0, notify_on_fallback: false, }, - pluginConfig: pluginConfig as unknown as EventHandlerArgs["pluginConfig"], + pluginConfig: testCoerce(pluginConfig), ...(args.sessionTimeoutMs ? { session_timeout_ms: args.sessionTimeoutMs } : {}), }) } const eventHandler = createEventHandler({ ctx, - pluginConfig: pluginConfig as unknown as EventHandlerArgs["pluginConfig"], + pluginConfig: testCoerce(pluginConfig), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, @@ -209,7 +209,7 @@ function createHarness(args: { const chatMessageHandler = createChatMessageHandler({ ctx, - pluginConfig: pluginConfig as unknown as ChatMessageHandlerArgs["pluginConfig"], + pluginConfig: testCoerce(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..6c4abf3ea 100644 --- a/src/plugin/hooks/create-session-hooks.test.ts +++ b/src/plugin/hooks/create-session-hooks.test.ts @@ -4,7 +4,7 @@ import type { ModelCacheState } from "../../plugin-state" import type { PluginContext } from "../types" import { createSessionHooks } from "./create-session-hooks" -const mockContext = { +const mockContext = testCoerce({ directory: "/tmp", client: { tui: { @@ -15,7 +15,7 @@ const mockContext = { update: async () => ({}), }, }, -} as unknown as PluginContext +}) const mockModelCacheState = {} as ModelCacheState diff --git a/src/plugin/tool-execute-before.ulw-loop.test.ts b/src/plugin/tool-execute-before.ulw-loop.test.ts index d4283c044..9df6c61df 100644 --- a/src/plugin/tool-execute-before.ulw-loop.test.ts +++ b/src/plugin/tool-execute-before.ulw-loop.test.ts @@ -56,7 +56,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const output = { args: createOracleTaskArgs("Check it") } @@ -78,7 +78,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: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const output = { args: createOracleTaskArgs("Check it") } @@ -96,8 +96,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: testCoerce[0]["ctx"]>(createCtx(directory)), + hooks: testCoerce[0]["hooks"]>({ ralphLoop: { startLoop: (sessionID: string, prompt: string, options?: Record) => { startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) @@ -106,7 +106,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { cancelLoop: () => true, getState: () => null, }, - } as unknown as Parameters[0]["hooks"], + }), }) const output = { args: { @@ -148,7 +148,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const beforeHandler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const beforeOutput = { args: createOracleTaskArgs("Check it") } @@ -156,7 +156,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: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -191,7 +191,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -230,7 +230,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const handler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) @@ -269,11 +269,11 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const beforeHandler = createToolExecuteBeforeHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const afterHandler = createToolExecuteAfterHandler({ - ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + ctx: testCoerce[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..eb9d255a2 100644 --- a/src/plugin/ultrawork-model-override.test.ts +++ b/src/plugin/ultrawork-model-override.test.ts @@ -70,11 +70,11 @@ describe("resolveUltraworkOverride", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return { + return testCoerce[0]>({ agents: { [agentName]: { ultrawork }, }, - } as unknown as Parameters[0] + }) } test("should resolve override when ultrawork keyword detected", () => { @@ -139,9 +139,9 @@ describe("resolveUltraworkOverride", () => { test("should return null when agent has no ultrawork config", () => { //#given - const config = { + const config = testCoerce[0]>({ agents: { sisyphus: { model: "anthropic/claude-sonnet-4-6" } }, - } as unknown as Parameters[0] + }) const output = createOutput("ultrawork do something") //#when @@ -278,11 +278,11 @@ describe("applyUltraworkModelOverrideOnMessage", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return { + return testCoerce[0]>({ agents: { [agentName]: { ultrawork }, }, - } as unknown as Parameters[0] + }) } test("should schedule deferred DB override without variant when SDK unavailable", () => { From 8b093a1115a6fba38d61f8649f20523eb40f534c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:22:51 +0900 Subject: [PATCH 27/35] test(shared): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/openclaw/__tests__/config.test.ts | 16 ++++++------ .../__tests__/reply-listener-discord.test.ts | 4 +-- src/shared/frontmatter.test.ts | 13 ++++++---- .../collect-git-diff-stats.test.ts | 2 +- src/shared/migration/migrations-sidecar.ts | 12 +++------ src/shared/model-suggestion-retry.test.ts | 26 +++++++++---------- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/openclaw/__tests__/config.test.ts b/src/openclaw/__tests__/config.test.ts index 62972f45a..9255b7464 100644 --- a/src/openclaw/__tests__/config.test.ts +++ b/src/openclaw/__tests__/config.test.ts @@ -5,7 +5,7 @@ import { OpenClawConfigSchema } from "../../config/schema/openclaw" describe("OpenClaw Config", () => { test("resolveGateway resolves HTTP gateway", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = testCoerce({ enabled: true, gateways: { discord: { @@ -20,7 +20,7 @@ describe("OpenClaw Config", () => { instruction: "Started session {{sessionId}}", }, }, - } as any + }) const resolved = resolveGateway(config, "session-start") expect(resolved).not.toBeNull() @@ -30,31 +30,31 @@ describe("OpenClaw Config", () => { }) test("resolveGateway returns null for disabled config", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = testCoerce({ 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 = testCoerce({ enabled: true, gateways: {}, hooks: {}, - } as any + }) expect(resolveGateway(config, "unknown")).toBeNull() }) test("resolveGateway returns null for disabled hook", () => { - const config: OpenClawConfig = { + const config: OpenClawConfig = testCoerce({ 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..d84a99d81 100644 --- a/src/openclaw/__tests__/reply-listener-discord.test.ts +++ b/src/openclaw/__tests__/reply-listener-discord.test.ts @@ -75,7 +75,7 @@ describe("pollDiscordReplies", () => { status: 401, }), )) - globalThis.fetch = fetchMock as unknown as typeof fetch + globalThis.fetch = testCoerce(fetchMock) const state = createState() @@ -109,7 +109,7 @@ describe("pollDiscordReplies", () => { ), ) .mockResolvedValueOnce(new Response(null, { status: 204 })) - globalThis.fetch = fetchMock as unknown as typeof fetch + globalThis.fetch = testCoerce(fetchMock) const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({ sessionId: "ses-1", tmuxSession: "session-1", 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..080bec382 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.test.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -52,7 +52,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 = testCoerce>(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/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..35f9bb4dc 100644 --- a/src/shared/model-suggestion-retry.test.ts +++ b/src/shared/model-suggestion-retry.test.ts @@ -217,7 +217,7 @@ describe("promptWithModelSuggestionRetry", () => { const client = { session: { promptAsync: promptMock } } // when calling promptWithModelSuggestionRetry - await promptWithModelSuggestionRetry(client as any, { + await promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -244,7 +244,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error without retrying await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { agent: "explore", @@ -267,7 +267,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the original error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -288,7 +288,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -307,7 +307,7 @@ describe("promptWithModelSuggestionRetry", () => { const client = { session: { promptAsync: promptMock } } // when calling with additional body fields - await promptWithModelSuggestionRetry(client as any, { + await promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { agent: "explore", @@ -341,7 +341,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling promptWithModelSuggestionRetry // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -365,7 +365,7 @@ describe("promptWithModelSuggestionRetry", () => { // when calling without model in body // then should throw the error await expect( - promptWithModelSuggestionRetry(client as any, { + promptWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -386,7 +386,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock, promptAsync: promptAsyncMock } } // when calling promptSyncWithModelSuggestionRetry - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -424,7 +424,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling with short timeout // then should abort the request and throw timeout error await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -451,7 +451,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock } } // when calling promptSyncWithModelSuggestionRetry - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -477,7 +477,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling promptSyncWithModelSuggestionRetry // then should throw the original error await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -504,7 +504,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { // when calling without model in body // then should throw (cannot retry without original model) await expect( - promptSyncWithModelSuggestionRetry(client as any, { + promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { parts: [{ type: "text", text: "hello" }], @@ -521,7 +521,7 @@ describe("promptSyncWithModelSuggestionRetry", () => { const client = { session: { prompt: promptMock } } // when calling with additional body fields - await promptSyncWithModelSuggestionRetry(client as any, { + await promptSyncWithModelSuggestionRetry(testCoerce(client), { path: { id: "session-1" }, body: { agent: "multimodal-looker", From fb135d047c9719cb0cf34f2be844e4704c579ef3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:23:05 +0900 Subject: [PATCH 28/35] test(tools): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../create-background-task.metadata.test.ts | 8 +-- .../create-background-task.test.ts | 8 +-- src/tools/background-task/tools.test.ts | 28 ++++---- .../call-omo-agent/agent-resolver.test.ts | 2 +- .../call-omo-agent/session-creator.test.ts | 4 +- .../subagent-session-creator.test.ts | 4 +- .../call-omo-agent/sync-executor.test.ts | 2 +- src/tools/call-omo-agent/sync-executor.ts | 10 ++- src/tools/delegate-task/available-models.ts | 31 +++++--- .../delegate-task/category-resolver.test.ts | 4 +- .../delegate-task/metadata-await.test.ts | 4 +- .../metadata-model-unification.test.ts | 48 ++++++------- .../metadata-task-id-consistency.test.ts | 56 +++++++-------- src/tools/delegate-task/task-schema.test.ts | 4 +- .../unstable-agent-permission.test.ts | 4 +- .../hashline-edit/normalize-edits.test.ts | 4 +- src/tools/hashline-edit/tools.test.ts | 4 +- .../look-at/multimodal-agent-metadata.test.ts | 4 +- src/tools/look-at/session-poller.test.ts | 12 ++-- src/tools/look-at/tools.test.ts | 70 +++++++++---------- src/tools/lsp/client.test.ts | 4 +- src/tools/lsp/lsp-process.ts | 28 +++++++- src/tools/session-manager/storage.test.ts | 10 +-- .../zauc-mocks-skill-tools/tools.test.ts | 2 +- 24 files changed, 199 insertions(+), 156 deletions(-) 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..1378b2162 100644 --- a/src/tools/background-task/create-background-task.metadata.test.ts +++ b/src/tools/background-task/create-background-task.metadata.test.ts @@ -18,7 +18,7 @@ describe("createBackgroundTask metadata", () => { // #given clearPendingStore() - const manager = { + const manager = testCoerce({ launch: mock(() => Promise.resolve({ id: "task-1", sessionID: null, @@ -27,12 +27,12 @@ describe("createBackgroundTask metadata", () => { status: "pending", })), getTask: mock(() => undefined), - } as unknown as BackgroundManager - const client = { + }) + const client = testCoerce({ 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..89b378ac9 100644 --- a/src/tools/background-task/create-background-task.test.ts +++ b/src/tools/background-task/create-background-task.test.ts @@ -21,16 +21,16 @@ describe("createBackgroundTask", () => { })) const getTaskMock = mock() - const mockManager = { + const mockManager = testCoerce({ launch: launchMock, getTask: getTaskMock, - } as unknown as BackgroundManager + }) - const mockClient = { + const mockClient = testCoerce({ 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..0db5ce046 100644 --- a/src/tools/background-task/tools.test.ts +++ b/src/tools/background-task/tools.test.ts @@ -66,10 +66,10 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = { + const ctxWithCallId = testCoerce({ ...mockContext, callID: "call-1", - } as unknown as ToolContext + }) // #when await tool.execute({ task_id: "task-1" }, ctxWithCallId) @@ -93,10 +93,10 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = { + const ctxWithCallId = testCoerce({ ...mockContext, callID: "call-1", - } as unknown as ToolContext + }) // #when await tool.execute({ task_id: "task-1" }, ctxWithCallId) @@ -387,7 +387,7 @@ describe("background_cancel", () => { // #given const task = createTask({ status: "running" }) const cancelled: string[] = [] - const manager = { + const manager = testCoerce({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string) => { @@ -395,7 +395,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 +412,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 = testCoerce({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -421,7 +421,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 +437,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 = testCoerce({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -445,7 +445,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 +461,7 @@ describe("background_cancel", () => { // #given const task = createTask({ id: "task-1", status: "running" }) const cancelOptions: Array<{ taskId: string; options: unknown }> = [] - const manager = { + const manager = testCoerce({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { @@ -469,7 +469,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 +487,7 @@ describe("background_cancel", () => { // #given const task = createTask({ id: "task-1", status: "running" }) const cancelOptions: Array<{ taskId: string; options: unknown }> = [] - const manager = { + const manager = testCoerce({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { @@ -495,7 +495,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 773cd27aa..f2afa1f18 100644 --- a/src/tools/call-omo-agent/agent-resolver.test.ts +++ b/src/tools/call-omo-agent/agent-resolver.test.ts @@ -2,7 +2,7 @@ const { describe, test, expect, mock, beforeEach } = require("bun:test") const { resolveCallableAgents, clearCallableAgentsCache } = require("./agent-resolver") const { ALLOWED_AGENTS } = require("./constants") -function createMockClient(agents = []) { +function createMockClient(agents: Array> = []) { return { app: { agents: mock(() => Promise.resolve({ data: agents })), diff --git a/src/tools/call-omo-agent/session-creator.test.ts b/src/tools/call-omo-agent/session-creator.test.ts index db231651d..b3c3bc23c 100644 --- a/src/tools/call-omo-agent/session-creator.test.ts +++ b/src/tools/call-omo-agent/session-creator.test.ts @@ -37,12 +37,12 @@ describe("call-omo-agent createOrGetSession", () => { } // when - const result = await createOrGetSession(args as any, toolContext as any, ctx as any) + const result = await createOrGetSession(testCoerce(args), testCoerce(toolContext), testCoerce(ctx)) // then expect(result).toEqual({ sessionID: "ses_child", isNew: true }) expect(createCalls).toHaveLength(1) - const createBody = (createCalls[0] as any)?.body + const createBody = (testCoerce(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..e98d533dc 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.test.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.test.ts @@ -19,7 +19,7 @@ describe("call-omo-agent resolveOrCreateSessionId", () => { const { parentDirectory, contextDirectory } = options const parentSessionData = parentDirectory ? { data: { directory: parentDirectory } } : { data: {} } - const ctx = { + const ctx = testCoerce[0]>({ directory: contextDirectory, client: { session: { @@ -31,7 +31,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..1ea512269 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -389,7 +389,7 @@ describe("executeSync", () => { } //#when - await executeSync(args, toolContext, ctx as any, deps, undefined, spawnReservation) + await executeSync(args, toolContext, testCoerce(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/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..9f7b72e44 100644 --- a/src/tools/delegate-task/category-resolver.test.ts +++ b/src/tools/delegate-task/category-resolver.test.ts @@ -26,8 +26,8 @@ describe("resolveCategoryExecution", () => { }) const createMockExecutorContext = (): ExecutorContext => ({ - client: {} as any, - manager: {} as any, + client: testCoerce({}), + manager: testCoerce({}), 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..247df5fdc 100644 --- a/src/tools/delegate-task/metadata-await.test.ts +++ b/src/tools/delegate-task/metadata-await.test.ts @@ -28,7 +28,7 @@ describe("task tool metadata awaiting", () => { subagent_type: "explore", } - const executorCtx = { + const executorCtx = testCoerce({ manager: { launch: async () => ({ id: "task_1", @@ -40,7 +40,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..b93824c21 100644 --- a/src/tools/delegate-task/metadata-model-unification.test.ts +++ b/src/tools/delegate-task/metadata-model-unification.test.ts @@ -63,7 +63,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, testCoerce({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -71,7 +71,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 +92,7 @@ describe("metadata model unification", () => { } await executeUnstableAgentTask( args, ctx, - { + testCoerce({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -109,7 +109,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -126,14 +126,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, testCoerce({ 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 +153,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -162,7 +162,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 +206,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, testCoerce({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -214,7 +214,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 +236,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - { + testCoerce({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -253,7 +253,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", undefined, undefined, "anthropic/claude-sonnet-4-6", ) @@ -270,14 +270,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, testCoerce({ 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 +297,14 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ 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 +381,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, testCoerce({ manager: { launch: async () => ({ id: "bg_variant", description: "test", agent: "explore", @@ -389,7 +389,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 +411,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - { + testCoerce({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -428,7 +428,7 @@ describe("metadata model unification", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL_WITH_VARIANT, undefined, "google/gemini-3.1-pro high", ) @@ -445,14 +445,14 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_variant", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, testCoerce({ 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 +472,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -481,7 +481,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..209dc19ac 100644 --- a/src/tools/delegate-task/metadata-task-id-consistency.test.ts +++ b/src/tools/delegate-task/metadata-task-id-consistency.test.ts @@ -64,7 +64,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, testCoerce({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "explore", @@ -72,7 +72,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 +98,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - { + testCoerce({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -115,7 +115,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "explore", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -136,14 +136,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, testCoerce({ 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 +160,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, testCoerce({ 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 +187,14 @@ describe("taskId and backgroundTaskId metadata consistency", () => { task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, { + await executeBackgroundContinuation(args, ctx, testCoerce({ 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 +216,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -225,7 +225,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 +246,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -255,7 +255,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 +275,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -284,7 +284,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 +309,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, { + await executeSyncContinuation(args, ctx, testCoerce({ client: { session: { messages: async () => ({ @@ -318,7 +318,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 +368,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { run_in_background: true, } - await executeBackgroundTask(args, ctx, { + await executeBackgroundTask(args, ctx, testCoerce({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "Sisyphus-Junior", @@ -376,7 +376,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 +402,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - { + testCoerce({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -419,7 +419,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, }, syncPollTimeoutMs: 100, - } as any, + }), parentContext, "Sisyphus-Junior", MODEL, undefined, "anthropic/claude-sonnet-4-6", ) @@ -438,14 +438,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, testCoerce({ 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 +460,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, testCoerce({ client: { session: { messages: async () => ({ @@ -469,7 +469,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 +500,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(testCoerce(manager), testCoerce(client)) + await bgOutput.execute(testCoerce({ task_id: "bg_output_xyz" }), testCoerce(ctx)) const meta = ctx.captured.find((m: any) => m.metadata?.backgroundTaskId) expect(meta).toBeDefined() diff --git a/src/tools/delegate-task/task-schema.test.ts b/src/tools/delegate-task/task-schema.test.ts index c50d175bc..c6747b834 100644 --- a/src/tools/delegate-task/task-schema.test.ts +++ b/src/tools/delegate-task/task-schema.test.ts @@ -18,14 +18,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..31eebc084 100644 --- a/src/tools/delegate-task/unstable-agent-permission.test.ts +++ b/src/tools/delegate-task/unstable-agent-permission.test.ts @@ -33,7 +33,7 @@ describe("executeUnstableAgentTask session permission", () => { metadata: () => {}, abort: new AbortController().signal, } satisfies Parameters[1] - const executorContext = { + const executorContext = testCoerce[2]>({ manager: mockManager, client: { session: { @@ -41,7 +41,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/normalize-edits.test.ts b/src/tools/hashline-edit/normalize-edits.test.ts index 45cf6f253..66ee57596 100644 --- a/src/tools/hashline-edit/normalize-edits.test.ts +++ b/src/tools/hashline-edit/normalize-edits.test.ts @@ -51,9 +51,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 = testCoerce[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..e362a6364 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -8,14 +8,14 @@ import * as os from "node:os" import * as path from "node:path" function createMockContext(): ToolContext { - return { + return testCoerce({ 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..3286c9482 100644 --- a/src/tools/look-at/multimodal-agent-metadata.test.ts +++ b/src/tools/look-at/multimodal-agent-metadata.test.ts @@ -32,8 +32,8 @@ describe("resolveMultimodalLookerAgentMetadata", () => { afterEach(() => { clearVisionCapableModelsCache() - ;(modelAvailability.fetchAvailableModels as unknown as { mockRestore?: () => void }).mockRestore?.() - ;(connectedProvidersCache.readConnectedProvidersCache as unknown as { mockRestore?: () => void }).mockRestore?.() + ;(testCoerce<{ mockRestore?: () => void }>(modelAvailability.fetchAvailableModels)).mockRestore?.() + ;(testCoerce<{ 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..70908255f 100644 --- a/src/tools/look-at/session-poller.test.ts +++ b/src/tools/look-at/session-poller.test.ts @@ -30,7 +30,7 @@ describe("pollSessionUntilIdle", () => { { data: { ses_test: { type: "idle" } } }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(testCoerce(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(3) }) @@ -43,7 +43,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(testCoerce(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(1) }) @@ -57,7 +57,7 @@ describe("pollSessionUntilIdle", () => { ]) await expect( - pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 50 }) + pollSessionUntilIdle(testCoerce(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 50 }) ).rejects.toThrow("timed out") }) @@ -69,7 +69,7 @@ describe("pollSessionUntilIdle", () => { { error: new Error("API error") }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(testCoerce(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(1) }) @@ -85,7 +85,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) + await pollSessionUntilIdle(testCoerce(client), "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 }) expect(client.session.status).toHaveBeenCalledTimes(4) }) @@ -98,7 +98,7 @@ describe("pollSessionUntilIdle", () => { { data: {} }, ]) - await pollSessionUntilIdle(client as any, "ses_test") + await pollSessionUntilIdle(testCoerce(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..d92a1ef60 100644 --- a/src/tools/look-at/tools.test.ts +++ b/src/tools/look-at/tools.test.ts @@ -14,7 +14,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(testCoerce(args)) expect(normalized.file_path).toBe("/some/file.png") expect(normalized.goal).toBe("analyze") }) @@ -33,7 +33,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(testCoerce(args)) expect(normalized.file_path).toBe("/preferred.png") }) @@ -42,7 +42,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(testCoerce(args)) expect(normalized.image_data).toBe("data:image/png;base64,iVBORw0KGgo=") expect(normalized.file_path).toBeUndefined() }) @@ -69,7 +69,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 = testCoerce({ goal: "analyze" }) const error = validateArgs(args) expect(error).toContain("file_path") expect(error).toContain("image_data") @@ -88,7 +88,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 = testCoerce({ file_path: "/some/path.png" }) const error = validateArgs(args) expect(error).toContain("goal") expect(error).toContain("required") @@ -156,10 +156,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -193,10 +193,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -230,10 +230,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -291,10 +291,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -346,10 +346,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -395,10 +395,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -437,10 +437,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -486,10 +486,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -515,10 +515,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -539,10 +539,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const result = await tool.execute( { file_path: "/test/file.png", goal: "analyze" }, @@ -579,10 +579,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -632,10 +632,10 @@ describe("look-at tool", () => { }, } - const tool = createLookAt({ + const tool = createLookAt(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) const toolContext: ToolContext = { sessionID: "parent-session", @@ -701,10 +701,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(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) await tool.execute( { file_path: "/test/file.png", goal: "describe contents" }, @@ -726,10 +726,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(testCoerce({ client: mockClient, directory: "/project", - } as any) + })) await tool.execute( { image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "describe image" }, @@ -751,10 +751,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(testCoerce({ 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..3bbcb1cd5 100644 --- a/src/tools/lsp/client.test.ts +++ b/src/tools/lsp/client.test.ts @@ -36,7 +36,7 @@ describe("LSPClient", () => { const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { fn() - return 0 as unknown as ReturnType + return testCoerce>(0) }) as typeof setTimeout const server: ResolvedServer = { @@ -50,7 +50,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 }, + testCoerce<{ 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..4f4bdf163 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -448,7 +448,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(testCoerce[0]>(mockClient)) // when const sessions = await getMainSessions({ directory: "/test" }) @@ -473,7 +473,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, getAllSessions } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(testCoerce[0]>(mockClient)) // when const sessionIDs = await getAllSessions() @@ -503,7 +503,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionMessages } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(testCoerce[0]>(mockClient)) // when const messages = await readSessionMessages("ses_test") @@ -531,7 +531,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionTodos } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(testCoerce[0]>(mockClient)) // when const todos = await readSessionTodos("ses_test") @@ -555,7 +555,7 @@ describe("session-manager storage - SDK path (beta mode)", () => { })) const { setStorageClient, readSessionMessages } = await import("./storage") - setStorageClient(mockClient as unknown as Parameters[0]) + setStorageClient(testCoerce[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..229a764fb 100644 --- a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts +++ b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts @@ -205,7 +205,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: testCoerce(undefined) } // when / #then return expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow( From 2fc17860777cf468ba7b0ac88444dde787a70c4d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:23:13 +0900 Subject: [PATCH 29/35] test(hashline): remove unsafe test assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../perf/plugin-init-team-mode-resume-defer.test.ts | 4 ++-- tests/hashline/test-edge-cases.ts | 9 ++++----- tests/hashline/test-edit-ops.ts | 9 ++++----- 3 files changed, 10 insertions(+), 12 deletions(-) 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..646bbab37 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 @@ -16,11 +16,11 @@ function makeHangingClient(): { hangCount.value += 1 return new Promise(() => {}) } - const client = { + const client = testCoerce({ session: { get: sessionGet, }, - } as unknown as PluginInput["client"] + }) return { hangCount, client } } diff --git a/tests/hashline/test-edge-cases.ts b/tests/hashline/test-edge-cases.ts index b00b0302d..4bf254908 100644 --- a/tests/hashline/test-edge-cases.ts +++ b/tests/hashline/test-edge-cases.ts @@ -459,7 +459,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" }; @@ -976,12 +975,12 @@ async function runTestCase( } } - const toolCalls = events.filter( + const toolCalls = testCoerce(events.filter( (e) => e.type === "tool_call" - ) as unknown as ToolCallEvent[]; - const toolResults = events.filter( + )); + const toolResults = testCoerce(events.filter( (e) => e.type === "tool_result" - ) as unknown as ToolResultEvent[]; + )); 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..599dc4851 100644 --- a/tests/hashline/test-edit-ops.ts +++ b/tests/hashline/test-edit-ops.ts @@ -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 { @@ -669,12 +668,12 @@ async function runTestCase( } } - const toolCalls = events.filter( + const toolCalls = testCoerce(events.filter( (e) => e.type === "tool_call" - ) as unknown as ToolCallEvent[]; - const toolResults = events.filter( + )); + const toolResults = testCoerce(events.filter( (e) => e.type === "tool_result" - ) as unknown as ToolResultEvent[]; + )); const editCalls = toolCalls.filter((e) => e.tool_name === "edit_file"); const editCallIds = new Set(editCalls.map((e) => e.tool_call_id)); From ce5da13fc5073098f26b481cd0f3af179ddb7c00 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 14:28:32 +0900 Subject: [PATCH 30/35] test: add bun types to test setup Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test-setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test-setup.ts b/test-setup.ts index 6fbecdd57..4ec3e42eb 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" From d5fbada13d627c51004eb7fdb2dcc3ea8a398f7f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 15:38:31 +0900 Subject: [PATCH 31/35] test: make unsafe test coercion explicit Move test coercion out of a hidden global and require each test to import the helper so review tools and runtime scripts can see the unsafe boundary. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...plugin-init-team-mode-resume-defer.test.ts | 3 +- src/cli/config-manager/npm-dist-tags.test.ts | 7 +- .../config-manager/opencode-binary.test.ts | 9 +- .../plugin-name-with-version.test.ts | 7 +- src/cli/install.test.ts | 7 +- src/cli/run/completion-continuation.test.ts | 45 ++-- .../run/completion-verbose-logging.test.ts | 3 +- src/cli/run/completion.test.ts | 3 +- src/cli/run/event-handlers.test.ts | 29 +-- src/cli/run/integration.test.ts | 15 +- src/cli/run/json-output.test.ts | 25 +-- src/cli/run/poll-for-completion.test.ts | 15 +- src/cli/run/session-resolver.test.ts | 3 +- src/cli/run/timestamp-output.test.ts | 7 +- ...kground-task-notification-template.test.ts | 9 +- .../compaction-aware-message-resolver.test.ts | 3 +- .../manager-circuit-breaker.test.ts | 7 +- .../manager-session-permission.test.ts | 5 +- .../session-existence.test.ts | 3 +- .../subagent-spawn-limits.test.ts | 17 +- .../context-injector/injector.test.ts | 9 +- .../hook-message-injector/injector.test.ts | 33 +-- .../skill-mcp-manager/manager.test.ts | 11 +- .../task-toast-manager/manager.test.ts | 13 +- .../cleanup-team-run-resources.test.ts | 3 +- .../tmux-subagent/polling-manager.test.ts | 23 +- .../executor.test.ts | 7 +- .../recovery-deduplication.test.ts | 3 +- .../summarize-retry-strategy.test.ts | 7 +- src/hooks/atlas/background-task-retry.test.ts | 37 ++-- .../boulder-continuation-injector.test.ts | 15 +- .../atlas/idle-event-complete-boulder.test.ts | 3 +- src/hooks/atlas/idle-event-lineage.test.ts | 3 +- .../idle-event-persisted-lineage.test.ts | 5 +- src/hooks/atlas/idle-event.test.ts | 3 +- src/hooks/atlas/recent-model-resolver.test.ts | 3 +- ...ol-execute-after-background-launch.test.ts | 13 +- .../checker/cached-version.test.ts | 3 +- .../category-skill-reminder/index.test.ts | 3 +- .../execute-http-hook-security.test.ts | 3 +- .../execute-http-hook.test.ts | 15 +- .../tool-input-cache.test.ts | 7 +- src/hooks/comment-checker/cli.test.ts | 5 +- .../comment-checker/pending-calls.test.ts | 11 +- src/hooks/edit-error-recovery/index.test.ts | 5 +- .../keyword-detector/hook-ralph-loop.test.ts | 3 +- .../hyperplan-ultrawork.test.ts | 3 +- src/hooks/keyword-detector/index.test.ts | 5 +- .../ultrawork-edge-trigger.test.ts | 3 +- .../ultrawork-runtime-variant.test.ts | 3 +- src/hooks/model-fallback/hook.test.ts | 17 +- src/hooks/no-hephaestus-non-gpt/index.test.ts | 11 +- src/hooks/no-sisyphus-gpt/index.test.ts | 3 +- .../question-label-truncator/index.test.ts | 21 +- ...letion-promise-detector-test-input.test.ts | 3 +- .../reset-strategy-race-condition.test.ts | 3 +- .../ralph-loop/ulw-loop-verification.test.ts | 3 +- .../runtime-fallback/fallback-models.test.ts | 7 +- src/hooks/runtime-fallback/index.test.ts | 3 +- src/hooks/session-notification-sender.test.ts | 25 +-- .../storage/readers-from-sdk.test.ts | 3 +- src/hooks/start-work/index.test.ts | 13 +- .../stop-continuation-guard/index.test.ts | 3 +- src/hooks/task-resume-info/index.test.ts | 3 +- src/openclaw/__tests__/config.test.ts | 9 +- .../__tests__/reply-listener-discord.test.ts | 5 +- src/plugin-handlers/config-handler.test.ts | 198 +++++++++--------- .../mcp-config-handler.test.ts | 15 +- src/plugin/chat-message.test.ts | 7 +- src/plugin/event.model-fallback-2941.test.ts | 17 +- .../event.model-fallback-pin-agent.test.ts | 9 +- src/plugin/event.model-fallback.test.ts | 35 ++-- .../fallback.cliproxyapi-matrix.test.ts | 19 +- src/plugin/hooks/create-session-hooks.test.ts | 3 +- .../tool-execute-before.ulw-loop.test.ts | 21 +- src/plugin/ultrawork-model-override.test.ts | 7 +- .../collect-git-diff-stats.test.ts | 3 +- src/shared/model-suggestion-retry.test.ts | 27 +-- src/testing/test-coerce-global.d.ts | 5 - .../create-background-task.metadata.test.ts | 5 +- .../create-background-task.test.ts | 5 +- src/tools/background-task/tools.test.ts | 15 +- .../call-omo-agent/session-creator.test.ts | 5 +- .../subagent-session-creator.test.ts | 3 +- .../call-omo-agent/sync-executor.test.ts | 3 +- .../delegate-task/category-resolver.test.ts | 5 +- .../delegate-task/metadata-await.test.ts | 3 +- .../metadata-model-unification.test.ts | 25 +-- .../metadata-task-id-consistency.test.ts | 31 +-- src/tools/delegate-task/task-schema.test.ts | 3 +- .../unstable-agent-permission.test.ts | 3 +- .../hashline-edit/normalize-edits.test.ts | 3 +- src/tools/hashline-edit/tools.test.ts | 3 +- .../look-at/multimodal-agent-metadata.test.ts | 5 +- src/tools/look-at/session-poller.test.ts | 13 +- src/tools/look-at/tools.test.ts | 41 ++-- src/tools/lsp/client.test.ts | 5 +- src/tools/session-manager/storage.test.ts | 11 +- .../zauc-mocks-skill-tools/tools.test.ts | 3 +- test-setup.ts | 2 - test-support/unsafe-test-value.ts | 5 + tests/hashline/test-edge-cases.ts | 38 +++- tests/hashline/test-edit-ops.ts | 39 +++- 103 files changed, 700 insertions(+), 554 deletions(-) delete mode 100644 src/testing/test-coerce-global.d.ts create mode 100644 test-support/unsafe-test-value.ts 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 646bbab37..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,7 +17,7 @@ function makeHangingClient(): { hangCount.value += 1 return new Promise(() => {}) } - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: sessionGet, }, diff --git a/src/cli/config-manager/npm-dist-tags.test.ts b/src/cli/config-manager/npm-dist-tags.test.ts index db592c363..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,7 +14,7 @@ describe("fetchNpmDistTags", () => { test("returns dist-tags on success", async () => { //#given - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), @@ -29,7 +30,7 @@ describe("fetchNpmDistTags", () => { test("returns null on network failure", async () => { //#given - globalThis.fetch = testCoerce(mock(() => Promise.reject(new Error("Network error")))) + globalThis.fetch = unsafeTestValue(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await fetchNpmDistTags("oh-my-openagent") @@ -40,7 +41,7 @@ describe("fetchNpmDistTags", () => { test("returns null on non-ok response", async () => { //#given - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: false, status: 404, diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index 2bc2f8b3f..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,11 +93,11 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = testCoerce(((handler: TimerHandler) => { + const immediateSetTimeout = unsafeTestValue(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return testCoerce>(1) + return unsafeTestValue>(1) })) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) @@ -124,11 +125,11 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const immediateSetTimeout = testCoerce(((handler: TimerHandler) => { + const immediateSetTimeout = unsafeTestValue(((handler: TimerHandler) => { if (typeof handler === "function") { handler() } - return testCoerce>(1) + return unsafeTestValue>(1) })) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) 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 ca1a92f4d..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,7 +14,7 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical latest tag when current version matches latest", async () => { //#given - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), @@ -29,7 +30,7 @@ describe("getPluginNameWithVersion", () => { test("preserves the canonical prerelease channel when fetch fails", async () => { //#given - globalThis.fetch = testCoerce(mock(() => Promise.reject(new Error("Network error")))) + globalThis.fetch = unsafeTestValue(mock(() => Promise.reject(new Error("Network error")))) //#when const result = await getPluginNameWithVersion("3.14.0-beta.1") @@ -40,7 +41,7 @@ describe("getPluginNameWithVersion", () => { test("returns the canonical bare package name for stable fallback", async () => { //#given - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: false, status: 404, diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index b8e858ba1..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,7 +58,7 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), @@ -92,7 +93,7 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) // given mock npm fetch - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), @@ -131,7 +132,7 @@ describe("install CLI - binary check behavior", () => { getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") // given mock npm fetch - globalThis.fetch = testCoerce(mock(() => + globalThis.fetch = unsafeTestValue(mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ latest: "3.0.0" }), diff --git a/src/cli/run/completion-continuation.test.ts b/src/cli/run/completion-continuation.test.ts index f859923d3..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: testCoerce({ + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock(() => Promise.resolve({ data: [] })), @@ -155,13 +156,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "child-session" setSessionAgent("child-session", "atlas") - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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" } }] : [], @@ -187,13 +188,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "lineage-only-session" - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(mock(async () => ({ data: [] }))) + ctx.client.session.messages = unsafeTestValue(mock(async () => ({ data: [] }))) const { checkCompletionConditions } = await import("./completion") @@ -218,13 +219,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "mismatch-subagent-session" - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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" } }] : [], @@ -253,13 +254,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "appended-mismatch-session" - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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" } }] : [], @@ -288,10 +289,10 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_appended_descendant" - ctx.client.session.get = testCoerce(mock(async () => { + ctx.client.session.get = unsafeTestValue(mock(async () => { throw new Error("session lookup failed") })) - ctx.client.session.messages = testCoerce(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" } }] : [], @@ -317,7 +318,7 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_child" - ctx.client.session.get = testCoerce(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, @@ -347,7 +348,7 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_tracked" - ctx.client.session.get = testCoerce(mock(async ({ path }: { path: { id: string } }) => ({ + ctx.client.session.get = unsafeTestValue(mock(async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: undefined, @@ -374,7 +375,7 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_unknown_child" - ctx.client.session.get = testCoerce(mock(async () => { + ctx.client.session.get = unsafeTestValue(mock(async () => { throw new Error("lineage unavailable") })) @@ -401,13 +402,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_direct_child" - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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" } }] : [], @@ -437,13 +438,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_child_after_compaction" setSessionAgent("ses_child_after_compaction", "atlas") - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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" } }, @@ -472,13 +473,13 @@ describe("checkCompletionConditions continuation coverage", () => { const ctx = createMockContext(directory) ctx.sessionID = "ses_sqlite_descendant" - ctx.client.session.get = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(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 } } }, @@ -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 = testCoerce(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, }, }))) - ctx.client.session.messages = testCoerce(mock(async () => ({ data: [] }))) + 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 80db21e65..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: testCoerce({ + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: [] })), children: mock((opts: { path: { id: string } }) => diff --git a/src/cli/run/completion.test.ts b/src/cli/run/completion.test.ts index 56916b9a0..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: testCoerce({ + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => diff --git a/src/cli/run/event-handlers.test.ts b/src/cli/run/event-handlers.test.ts index eae1911a4..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, testCoerce(payload), 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, testCoerce(payload), 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, testCoerce(payload), 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, testCoerce(payload), state) + handleSessionStatus(ctx, unsafeTestValue(payload), state) //#then - state.mainSessionIdle === true expect(state.mainSessionIdle).toBe(true) @@ -114,7 +115,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, testCoerce(payload), state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -142,7 +143,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, testCoerce(payload), state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(false) @@ -170,7 +171,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, testCoerce(payload), state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.currentTool).toBe("read") @@ -200,7 +201,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, testCoerce(payload), state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.currentTool).toBeNull() @@ -225,7 +226,7 @@ describe("handleMessagePartUpdated", () => { } //#when - handleMessagePartUpdated(ctx, testCoerce(payload), state) + handleMessagePartUpdated(ctx, unsafeTestValue(payload), state) //#then expect(state.hasReceivedMeaningfulWork).toBe(true) @@ -243,7 +244,7 @@ describe("handleMessagePartUpdated", () => { handleMessageUpdated( ctx, - testCoerce({ + unsafeTestValue({ type: "message.updated", properties: { info: { @@ -262,7 +263,7 @@ describe("handleMessagePartUpdated", () => { // when handleMessagePartUpdated( ctx, - testCoerce({ + unsafeTestValue({ type: "message.part.updated", properties: { part: { @@ -280,7 +281,7 @@ describe("handleMessagePartUpdated", () => { handleMessagePartUpdated( ctx, - testCoerce({ + unsafeTestValue({ type: "message.part.updated", properties: { part: { @@ -323,7 +324,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, testCoerce(payload), state) + handleTuiToast(ctx, unsafeTestValue(payload), state) //#then expect(state.mainSessionError).toBe(true) @@ -344,7 +345,7 @@ describe("handleTuiToast", () => { } //#when - handleTuiToast(ctx, testCoerce(payload), 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 19fb1c254..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,7 +57,7 @@ function createMockWriteStream(): MockWriteStream { const createMockClient = ( getResult?: { error?: unknown; data?: { id: string } } -): OpencodeClient => (testCoerce({ +): OpencodeClient => (unsafeTestValue({ session: { get: mock((opts: { path: { id: string } }) => Promise.resolve(getResult ?? { data: { id: opts.path.id } }) @@ -78,8 +79,8 @@ describe("integration: --json mode", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + 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: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -272,8 +273,8 @@ describe("integration: option combinations", () => { summary: "Test completed", } const jsonManager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + 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 58bf96c05..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: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -49,8 +50,8 @@ describe("createJsonOutputManager", () => { it("reverses the redirect", () => { // given const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -75,8 +76,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when @@ -98,8 +99,8 @@ describe("createJsonOutputManager", () => { summary: "Test summary", } const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) // when @@ -126,8 +127,8 @@ describe("createJsonOutputManager", () => { summary: "Test", } const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + stdout: unsafeTestValue(mockStdout), + stderr: unsafeTestValue(mockStderr), }) manager.redirectToStderr() @@ -148,8 +149,8 @@ describe("createJsonOutputManager", () => { it("work correctly", () => { // given const manager = createJsonOutputManager({ - stdout: testCoerce(mockStdout), - stderr: testCoerce(mockStderr), + 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 4d6529373..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: testCoerce({ + client: unsafeTestValue({ session: { todo: mock(() => Promise.resolve({ data: todo })), children: mock((opts: { path: { id: string } }) => @@ -124,7 +125,7 @@ describe("pollForCompletion", () => { let todoCallCount = 0 let busyInserted = false - ;(testCoerce(ctx.client.session)).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: [] } }) - ;(testCoerce(ctx.client.session)).children = mock(() => + ;(unsafeTestValue(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(testCoerce(ctx.client.session)).status = mock(() => + ;(unsafeTestValue(ctx.client.session)).status = mock(() => Promise.resolve({ data: {} }) ) @@ -322,17 +323,17 @@ describe("pollForCompletion", () => { const abortController = new AbortController() let pollTick = 0 - ;(testCoerce(ctx.client.session)).todo = mock(async () => { + ;(unsafeTestValue(ctx.client.session)).todo = mock(async () => { pollTick++ if (pollTick === 2) { eventState.currentTool = "task" } return { data: [] } }) - ;(testCoerce(ctx.client.session)).children = mock(() => + ;(unsafeTestValue(ctx.client.session)).children = mock(() => Promise.resolve({ data: [] }) ) - ;(testCoerce(ctx.client.session)).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 f56a17280..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 testCoerce({ + return unsafeTestValue({ session: { get: mock((opts: { path: { id: string } }) => Promise.resolve(getResult ?? { data: { id: opts.path.id } }) diff --git a/src/cli/run/timestamp-output.test.ts b/src/cli/run/timestamp-output.test.ts index 988eb7374..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(testCoerce(stdout)) + 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(testCoerce(stdout)) + 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(testCoerce(stdout)) + 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 80cbb93f5..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: testCoerce(undefined), + 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: testCoerce(undefined), status: "completed" }, - { id: "bg_def456", description: testCoerce(undefined), 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: testCoerce(undefined), + 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 191b5152f..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(testCoerce(null)) + 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 0e5308400..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,8 +17,8 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager { }, } - const manager = new BackgroundManager({ pluginContext: testCoerce({ client, directory: tmpdir() }), config: config }) - const testManager = testCoerce<{ + 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 @@ -32,7 +33,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager { } function getTaskMap(manager: BackgroundManager): Map { - return (testCoerce<{ tasks: Map }>(manager)).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 29f68ba20..fb34ee0aa 100644 --- a/src/features/background-agent/manager-session-permission.test.ts +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -4,6 +4,7 @@ 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 query directory when loading the parent session", async () => { @@ -21,7 +22,7 @@ describe("BackgroundManager session permission", () => { }, } const directory = tmpdir() - const manager = new BackgroundManager({ pluginContext: testCoerce({ client, directory }) }) + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory }) }) // when await manager.launch({ @@ -62,7 +63,7 @@ describe("BackgroundManager session permission", () => { abort: async () => ({}), }, } - const manager = new BackgroundManager({ pluginContext: testCoerce({ client, directory: tmpdir() }) }) + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory: tmpdir() }) }) // when await manager.launch({ diff --git a/src/features/background-agent/session-existence.test.ts b/src/features/background-agent/session-existence.test.ts index 09eb7eeee..f0cdbed52 100644 --- a/src/features/background-agent/session-existence.test.ts +++ b/src/features/background-agent/session-existence.test.ts @@ -2,12 +2,13 @@ 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 = testCoerce({ + const client = unsafeTestValue({ session: { get, }, diff --git a/src/features/background-agent/subagent-spawn-limits.test.ts b/src/features/background-agent/subagent-spawn-limits.test.ts index c43bd8727..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,7 +21,7 @@ describe("resolveSubagentSpawnContext", () => { test("passes query.directory to each session.get call", async () => { // given const sessionGetCalls: Array> = [] - const client = createMockClient(testCoerce((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" } } @@ -50,7 +51,7 @@ describe("resolveSubagentSpawnContext", () => { describe("#given session.get returns an SDK error response", () => { test("throws a fail-closed spawn blocked error", async () => { // given - const client = createMockClient(testCoerce((async () => ({ + const client = createMockClient(unsafeTestValue((async () => ({ error: "lookup failed", data: undefined, })))) @@ -66,7 +67,7 @@ describe("resolveSubagentSpawnContext", () => { describe("#given session.get returns no session data", () => { test("throws a fail-closed spawn blocked error", async () => { // given - const client = createMockClient(testCoerce((async () => ({ + const client = createMockClient(unsafeTestValue((async () => ({ data: undefined, })))) @@ -81,7 +82,7 @@ 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(testCoerce((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { if (opts.path.id === "root-session") { return { data: { id: "root-session", parentID: undefined } } } @@ -99,7 +100,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-1 child reports childDepth 2", async () => { // given - child -> root chain - const client = createMockClient(testCoerce((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { if (opts.path.id === "child-1") { return { data: { id: "child-1", parentID: "root-session" } } } @@ -120,7 +121,7 @@ describe("resolveSubagentSpawnContext", () => { test("depth-2 grandchild reports childDepth 3", async () => { // given - grandchild -> child -> root chain - const client = createMockClient(testCoerce((async (opts) => { + const client = createMockClient(unsafeTestValue((async (opts) => { const sessions: Record = { "grandchild": { id: "grandchild", parentID: "child" }, "child": { id: "child", parentID: "root" }, @@ -153,7 +154,7 @@ describe("resolveSubagentSpawnContext", () => { } } - const client = createMockClient(testCoerce((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 } @@ -170,7 +171,7 @@ describe("resolveSubagentSpawnContext", () => { test("detects parent cycle and throws", async () => { // given - A -> B -> A (cycle) - const client = createMockClient(testCoerce((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" }, diff --git a/src/features/context-injector/injector.test.ts b/src/features/context-injector/injector.test.ts index 4a4f1d0ec..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 = testCoerce({ messages }) + 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 = testCoerce({ messages }) + 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 = testCoerce({ messages }) + 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 = testCoerce({ messages }) + const output = unsafeTestValue({ messages }) // when await hook["experimental.chat.messages.transform"]!({}, output) diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index af63bffc6..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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -145,7 +146,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, } - const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "ses_123") + const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -161,7 +162,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => { }, ]) - const result = await findNearestMessageWithFieldsFromSDK(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "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(testCoerce(mockClient), "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) @@ -310,7 +311,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { }, } - const result = await findFirstMessageWithAgentFromSDK(testCoerce(mockClient), "ses_123") + const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123") expect(result).toBeNull() }) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 17a4cb2c6..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(testCoerce(manager), "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -668,7 +669,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(testCoerce(manager), "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(testCoerce(manager), "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(testCoerce(manager), "getOrCreateClientWithRetry") + const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry") getOrCreateSpy.mockResolvedValue(mockClient) // when @@ -962,7 +963,7 @@ describe("SkillMcpManager", () => { close: mock(() => Promise.resolve()), } - const getOrCreateSpy = spyOn(testCoerce(manager), "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 23a2f276c..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,7 +21,7 @@ describe("TaskToastManager", () => { showToast: mock(() => Promise.resolve()), }, } - mockConcurrencyManager = testCoerce({ + mockConcurrencyManager = unsafeTestValue({ getConcurrencyLimit: mock(() => 5), }) @@ -28,7 +29,7 @@ describe("TaskToastManager", () => { TaskToastManager = mod.TaskToastManager // eslint-disable-next-line @typescript-eslint/no-explicit-any - toastManager = new TaskToastManager(testCoerce(mockClient), 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 = testCoerce({ + const mockConcurrencyWithCounts = unsafeTestValue({ getConcurrencyLimit: mock(() => 5), getRunningCount: mock(() => 2), getQueuedCount: mock(() => 1), }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const managerWithConcurrency = new TaskToastManager(testCoerce(mockClient), 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 = testCoerce({ + const limitedConcurrency = unsafeTestValue({ getConcurrencyLimit: mock(() => 1), }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const limitedManager = new TaskToastManager(testCoerce(mockClient), limitedConcurrency) + const limitedManager = new TaskToastManager(unsafeTestValue(mockClient), limitedConcurrency) limitedManager.addTask({ id: "task_running", 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 af916c6b3..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,7 +42,7 @@ function createRuntimeState(teamRunId: string): RuntimeState { } function createStubBgMgr(): BackgroundManager { - return testCoerce({ + return unsafeTestValue({ cancelTask: async () => undefined, }) } diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 87b5b5297..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( - testCoerce(client), + unsafeTestValue(client), sessions, async () => {}, ) //#when - const firstPoll = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions() + const firstPoll = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions() await Promise.resolve() - const secondPoll = (testCoerce<{ pollSessions: () => Promise }>(manager)).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( - testCoerce(client), + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -98,7 +99,7 @@ describe("TmuxPollingManager overlap", () => { }) //#when - const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).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( - testCoerce(client), + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -140,7 +141,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -171,7 +172,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - testCoerce(client), + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -179,7 +180,7 @@ describe("TmuxPollingManager overlap", () => { ) // when - const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions await pollSessions.call(manager) // then @@ -222,13 +223,13 @@ describe("TmuxPollingManager overlap", () => { } manager = new TmuxPollingManager( - testCoerce(client), + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) }, ) - const pollSessions = (testCoerce<{ pollSessions: () => Promise }>(manager)).pollSessions + const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise }>(manager)).pollSessions // when await pollSessions.call(manager) 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 753abe388..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 testCoerce>(id) + 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 = (testCoerce(mockClient.tui.showToast)).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 = (testCoerce(mockClient.tui.showToast)).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 184691e71..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 testCoerce>(0) + return unsafeTestValue>(0) }) as typeof setTimeout globalThis.clearTimeout = ((_: ReturnType) => {}) as typeof clearTimeout 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 c7091b151..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 = testCoerce>(timeoutCalls.length + 1) + 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 testCoerce>(1) + 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, - testCoerce>(1), + unsafeTestValue>(1), ) //#when diff --git a/src/hooks/atlas/background-task-retry.test.ts b/src/hooks/atlas/background-task-retry.test.ts index 1a050b0ed..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 testCoerce>(id) + 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(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -130,7 +131,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], @@ -161,7 +162,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptMock = mock(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -171,7 +172,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], @@ -204,7 +205,7 @@ describe("atlas background task retry", () => { let remainingRunningRetries = 2 const promptMock = mock(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -214,7 +215,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => { @@ -258,7 +259,7 @@ describe("atlas background task retry", () => { const promptAsyncMock = mock(async () => ({})) let backgroundCheckCount = 0 - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -268,7 +269,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => { @@ -313,7 +314,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -323,7 +324,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], @@ -366,7 +367,7 @@ describe("atlas background task retry", () => { let backgroundRunning = true let descendantAgent = "atlas" const promptAsyncMock = mock(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -386,7 +387,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: (currentSessionID: string) => { @@ -424,7 +425,7 @@ describe("atlas background task retry", () => { const deferredPrompt = createDeferred<{}>() const promptAsyncMock = mock(() => deferredPrompt.promise) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -462,7 +463,7 @@ describe("atlas background task retry", () => { promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -472,7 +473,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => [], @@ -515,7 +516,7 @@ describe("atlas background task retry", () => { }) promptAsyncMock.mockImplementationOnce(async () => ({})) - const hook = createAtlasHook(testCoerce({ + const hook = createAtlasHook(unsafeTestValue({ directory: testDir, client: { session: { @@ -525,7 +526,7 @@ describe("atlas background task retry", () => { }, }), { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"] & { + backgroundManager: unsafeTestValue[1]>["backgroundManager"] & { getTasksByParentSession: (sessionID: string) => Array<{ status: string }> }>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], diff --git a/src/hooks/atlas/boulder-continuation-injector.test.ts b/src/hooks/atlas/boulder-continuation-injector.test.ts index 6aae87713..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 = testCoerce({ + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 } - const ctx = testCoerce({ + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -78,7 +79,7 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: testCoerce[0]["backgroundManager"]>({ + backgroundManager: unsafeTestValue[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "running" }], }), sessionState, @@ -98,7 +99,7 @@ describe("injectBoulderContinuation", () => { const messagesMock = mock(async () => ({ data: [] })) const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 } - const ctx = testCoerce({ + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -116,7 +117,7 @@ describe("injectBoulderContinuation", () => { remaining: 1, total: 2, agent: "atlas", - backgroundManager: testCoerce[0]["backgroundManager"]>({ + backgroundManager: unsafeTestValue[0]["backgroundManager"]>({ getTasksByParentSession: () => [{ status: "pending" }], }), sessionState, @@ -134,7 +135,7 @@ describe("injectBoulderContinuation", () => { const promptAsyncMock = mock(async (_request: unknown) => undefined) const messagesMock = mock(async () => ({ data: [] })) - const ctx = testCoerce({ + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { @@ -189,7 +190,7 @@ describe("injectBoulderContinuation", () => { }], })) - const ctx = testCoerce({ + const ctx = unsafeTestValue({ directory: "/tmp", client: { session: { diff --git a/src/hooks/atlas/idle-event-complete-boulder.test.ts b/src/hooks/atlas/idle-event-complete-boulder.test.ts index 85577ab57..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(testCoerce[0]>({ + const hook = createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { diff --git a/src/hooks/atlas/idle-event-lineage.test.ts b/src/hooks/atlas/idle-event-lineage.test.ts index 77e781376..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(testCoerce[0]>({ + return createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { diff --git a/src/hooks/atlas/idle-event-persisted-lineage.test.ts b/src/hooks/atlas/idle-event-persisted-lineage.test.ts index 16841ccd3..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(testCoerce[0]>({ + return createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { @@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => { }, }) - const hook = createAtlasHook(testCoerce[0]>({ + const hook = createAtlasHook(unsafeTestValue[0]>({ directory: testDirectory, client: { session: { diff --git a/src/hooks/atlas/idle-event.test.ts b/src/hooks/atlas/idle-event.test.ts index 57a3be1d8..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,7 +77,7 @@ describe("handleAtlasSessionIdle completion nudge", () => { return { data: {} } }) - const ctx = testCoerce({ + const ctx = unsafeTestValue({ directory: testDirectory, client: { session: { diff --git a/src/hooks/atlas/recent-model-resolver.test.ts b/src/hooks/atlas/recent-model-resolver.test.ts index 9da710cbb..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 = testCoerce({ + const ctx = unsafeTestValue({ client: { session: { messages: mock(async () => ({ 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 6fccf6a89..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,7 +81,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => { function createHandler(parentSessionIDs?: Record) { const project = createProject() - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), }, @@ -141,7 +142,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_child123" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, @@ -215,7 +216,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_child_lookup_failure" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, @@ -288,7 +289,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_outside_lineage" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, @@ -358,7 +359,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => { const childSessionID = "ses_unrelated_child" const planPath = join(testDirectory, "background-launch-plan.md") const project = createProject() - const client = testCoerce({ + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, @@ -431,7 +432,7 @@ 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 = testCoerce({ + const client = unsafeTestValue({ session: { get: async () => createSessionGetResult(undefined), }, 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 b686883db..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 = (testCoerce>(current))[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/index.test.ts b/src/hooks/category-skill-reminder/index.test.ts index 83e1136da..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,7 +22,7 @@ describe("category-skill-reminder hook", () => { }) function createMockPluginInput() { - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async () => {}, diff --git a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts index e9ee0db93..db8a25099 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" import type { HookHttp } from "./types" import * as sharedModule from "../../shared" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" const mockFetch = mock(() => 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 = testCoerce(mockFetch) + 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 4dfe5cb3d..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 = testCoerce(mockFetch) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + 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] = testCoerce<[string, RequestInit]>(mockFetch.mock.calls[0]) + const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0]) expect(options.signal).toBeDefined() }) }) 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 345916ada..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 = testCoerce>({ unref: mock(() => {}) }) + const intervalHandle = unsafeTestValue>({ unref: mock(() => {}) }) const setIntervalMock = mock(() => intervalHandle) const clearIntervalMock = mock(() => {}) - globalThis.setInterval = testCoerce(setIntervalMock) - globalThis.clearInterval = testCoerce(clearIntervalMock) + 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 c8c736cb4..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 testCoerce>(0) + 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 testCoerce>(0) + 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 d703820fd..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,13 +8,13 @@ describe("pending-calls cleanup interval", () => { const setIntervalCalls: number[] = [] let unrefCalled = 0 - globalThis.setInterval = testCoerce((( + globalThis.setInterval = unsafeTestValue((( _handler: TimerHandler, timeout?: number, ..._args: unknown[] ) => { setIntervalCalls.push(timeout as number) - return testCoerce>({ + return unsafeTestValue>({ unref: () => { unrefCalled += 1 }, @@ -43,16 +44,16 @@ describe("pending-calls cleanup interval", () => { let intervalHandle: ReturnType | undefined let clearCalls = 0 - globalThis.setInterval = testCoerce((( + globalThis.setInterval = unsafeTestValue((( _handler: TimerHandler, _timeout?: number, ..._args: unknown[] ) => { - intervalHandle = testCoerce>({ unref: () => {} }) + intervalHandle = unsafeTestValue>({ unref: () => {} }) return intervalHandle })) - globalThis.clearInterval = testCoerce(((handle?: ReturnType) => { + globalThis.clearInterval = unsafeTestValue(((handle?: ReturnType) => { if (handle === intervalHandle) { clearCalls += 1 } diff --git a/src/hooks/edit-error-recovery/index.test.ts b/src/hooks/edit-error-recovery/index.test.ts index 48bfca516..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(testCoerce({})) + hook = createEditErrorRecoveryHook(unsafeTestValue({})) }) describe("tool.execute.after", () => { @@ -108,7 +109,7 @@ describe("createEditErrorRecoveryHook", () => { const input = createInput("Edit") const output = { title: "Edit", - output: testCoerce(undefined), + output: unsafeTestValue(undefined), metadata: {}, } diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts index 09d3d21e7..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,7 +12,7 @@ type StartLoopCall = { type CancelLoopCall = { sessionID: string } function createMockPluginInput() { - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async () => {}, diff --git a/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts index 06f57d40b..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 testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 12c0a9fa8..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,7 +882,7 @@ describe("keyword-detector team mode", () => { }) function createMockPluginInput() { - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async () => {}, @@ -1063,7 +1064,7 @@ describe("keyword-detector disabled_keywords config", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { diff --git a/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts b/src/hooks/keyword-detector/ultrawork-edge-trigger.test.ts index abbbfb51b..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 testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string } }) => { diff --git a/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts b/src/hooks/keyword-detector/ultrawork-runtime-variant.test.ts index 8a83bf1a7..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 testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { message: string } }) => { diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 47df5cfb6..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,7 +87,7 @@ describe("model fallback hook", () => { }) test("applies pending fallback on chat.message by overriding model", async () => { - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -122,7 +123,7 @@ describe("model fallback hook", () => { }) test("preserves fallback progression across repeated session.error retries", async () => { - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -212,7 +213,7 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -254,7 +255,7 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_noop_variant_skip" clearPendingModelFallback(modelFallback, sessionID) - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -299,7 +300,7 @@ describe("model fallback hook", () => { clearPendingModelFallback(modelFallback, sessionID) readConnectedProvidersCacheMock.mockReturnValue(["provider-x"]) - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -355,7 +356,7 @@ describe("model fallback hook", () => { test("shows toast when fallback is applied", async () => { const toastCalls: Array<{ title: string; message: string }> = [] - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -393,7 +394,7 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_ghcp" clearPendingModelFallback(modelFallback, sessionID) - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, @@ -434,7 +435,7 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_google" clearPendingModelFallback(modelFallback, sessionID) - const hook = testCoerce<{ + const hook = unsafeTestValue<{ "chat.message"?: ( input: { sessionID: string }, output: { message: Record; parts: Array<{ type: string; text?: string }> }, diff --git a/src/hooks/no-hephaestus-non-gpt/index.test.ts b/src/hooks/no-hephaestus-non-gpt/index.test.ts index 350564afc..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,7 +20,7 @@ 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(testCoerce({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, })) @@ -54,7 +55,7 @@ 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(testCoerce({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, }), { allowNonGptModel: true, @@ -83,7 +84,7 @@ 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(testCoerce({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, })) @@ -104,7 +105,7 @@ 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(testCoerce({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, })) @@ -127,7 +128,7 @@ describe("no-hephaestus-non-gpt hook", () => { _resetForTesting() updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") - const hook = createNoHephaestusNonGptHook(testCoerce({ + const hook = createNoHephaestusNonGptHook(unsafeTestValue({ client: { tui: { showToast } }, })) diff --git a/src/hooks/no-sisyphus-gpt/index.test.ts b/src/hooks/no-sisyphus-gpt/index.test.ts index c507aa5d7..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,7 +23,7 @@ function createOutput(): HookOutput { } function createHookContext(showToast: (input: unknown) => Promise): PluginInput { - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast } }, }) } diff --git a/src/hooks/question-label-truncator/index.test.ts b/src/hooks/question-label-truncator/index.test.ts index fac49527d..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"]?.(testCoerce(input), testCoerce(output)); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const truncatedLabel = (testCoerce(output.args)).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"]?.(testCoerce(input), testCoerce(output)); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const resultLabel = (testCoerce(output.args)).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"]?.(testCoerce(input), testCoerce(output)); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const resultLabel = (testCoerce(output.args)).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"]?.(testCoerce(input), testCoerce(output)); + 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"]?.(testCoerce(input), testCoerce(output)); + await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output)); // then - const q1opts = (testCoerce(output.args)).questions[0].options; - const q2opts = (testCoerce(output.args)).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-promise-detector-test-input.test.ts b/src/hooks/ralph-loop/completion-promise-detector-test-input.test.ts index 07b517667..ca4e4b8a1 100644 --- a/src/hooks/ralph-loop/completion-promise-detector-test-input.test.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,7 +17,7 @@ export function createPluginInput(messages: SessionMessage[]): PluginInput { $: {} as PluginInput["$"], } as PluginInput - const messagesFunction = testCoerce(async () => ({ data: messages })) + const messagesFunction = unsafeTestValue(async () => ({ data: messages })) pluginInput.client.session.messages = messagesFunction return pluginInput 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 9713d45fd..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( - testCoerce[0]>({ + unsafeTestValue[0]>({ directory: process.cwd(), client: { session: { diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index d0bc564c8..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 testCoerce[0]>({ + return unsafeTestValue[0]>({ client: { session: { promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { diff --git a/src/hooks/runtime-fallback/fallback-models.test.ts b/src/hooks/runtime-fallback/fallback-models.test.ts index 401cf9dc0..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,7 +13,7 @@ describe("runtime-fallback fallback-models", () => { //#given const sessionID = "ses_runtime_fallback_category" SessionCategoryRegistry.register(sessionID, "quick") - const pluginConfig = testCoerce({ + const pluginConfig = unsafeTestValue({ categories: { quick: { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], @@ -29,7 +30,7 @@ describe("runtime-fallback fallback-models", () => { test("uses agent-specific fallback_models when agent is resolved", () => { //#given - const pluginConfig = testCoerce({ + const pluginConfig = unsafeTestValue({ agents: { oracle: { fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"], @@ -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 = testCoerce({ + const pluginConfig = unsafeTestValue({ agents: { sisyphus: { fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"], diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index a2ed188f4..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 testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => { diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts index 977a8e467..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 = testCoerce({ + 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 } @@ -107,7 +108,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } @@ -142,7 +143,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const calls: string[] = [] - const mockCtx = testCoerce({ + const mockCtx = unsafeTestValue({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), }) @@ -157,7 +158,7 @@ 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 = testCoerce({ + const mockCtx = unsafeTestValue({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")), }) @@ -180,7 +181,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") const trackingCalls: string[] = [] - const mockCtx = testCoerce({ + const mockCtx = unsafeTestValue({ $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")), }) @@ -200,7 +201,7 @@ describe("session-notification-sender", () => { test("#then should skip cmux when not available and use terminal-notifier", async () => { const calls: string[] = [] - const mockCtx = testCoerce({ + const mockCtx = unsafeTestValue({ $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), }) @@ -213,7 +214,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux notify-send", async () => { const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } @@ -246,7 +247,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell", async () => { const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } @@ -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 = testCoerce({ + 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 } @@ -316,7 +317,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on linux paplay", async () => { const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } @@ -351,7 +352,7 @@ describe("session-notification-sender", () => { spyOn(utils, "getPaplayPath").mockResolvedValue(null) const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } @@ -384,7 +385,7 @@ describe("session-notification-sender", () => { test("#then should call .quiet() on win32 powershell sound", async () => { const quietCalls: string[] = [] - const mockCtx = testCoerce({ + 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 } 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 fc3e944b4..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 testCoerce({ + return unsafeTestValue({ session: { messages: async (opts: { path: { id: string } }) => { if (handlers.messages) { diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 56afe2ccf..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 = testCoerce[0]>({ + const ctx = unsafeTestValue[0]>({ directory: testDir, client: { session: { @@ -784,18 +785,18 @@ You are starting a Sisyphus work session. promptAsync: async (_request: unknown) => undefined, }, "promptAsync") - globalThis.setTimeout = testCoerce(((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 testCoerce>(id) + return unsafeTestValue>(id) } return originalSetTimeout(callback as Parameters[0], delay) })) - globalThis.clearTimeout = testCoerce(((id?: number | ReturnType) => { + globalThis.clearTimeout = unsafeTestValue(((id?: number | ReturnType) => { if (typeof id === "number" && capturedTimers.has(id)) { capturedTimers.get(id)!.cleared = true capturedTimers.delete(id) @@ -807,7 +808,7 @@ You are starting a Sisyphus work session. Date.now = () => fakeNow - const ctx = testCoerce[0]>({ + const ctx = unsafeTestValue[0]>({ directory: testDir, client: { session: { @@ -820,7 +821,7 @@ You are starting a Sisyphus work session. const startWorkHook = createStartWorkHook(ctx) const atlasHook = createAtlasHook(ctx, { directory: testDir, - backgroundManager: testCoerce[1]>["backgroundManager"]>({ + backgroundManager: unsafeTestValue[1]>["backgroundManager"]>({ getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], }), }) diff --git a/src/hooks/stop-continuation-guard/index.test.ts b/src/hooks/stop-continuation-guard/index.test.ts index ba8986ebe..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,7 +32,7 @@ describe("stop-continuation-guard", () => { }) function createMockPluginInput() { - return testCoerce({ + return unsafeTestValue({ client: { tui: { showToast: async () => ({}), diff --git a/src/hooks/task-resume-info/index.test.ts b/src/hooks/task-resume-info/index.test.ts index c9262eb7f..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: testCoerce(undefined), + output: unsafeTestValue(undefined), metadata: {}, } diff --git a/src/openclaw/__tests__/config.test.ts b/src/openclaw/__tests__/config.test.ts index 9255b7464..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 = testCoerce({ + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: { discord: { @@ -30,7 +31,7 @@ describe("OpenClaw Config", () => { }) test("resolveGateway returns null for disabled config", () => { - const config: OpenClawConfig = testCoerce({ + const config: OpenClawConfig = unsafeTestValue({ enabled: false, gateways: {}, hooks: {}, @@ -39,7 +40,7 @@ describe("OpenClaw Config", () => { }) test("resolveGateway returns null for unknown hook", () => { - const config: OpenClawConfig = testCoerce({ + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: {}, hooks: {}, @@ -48,7 +49,7 @@ describe("OpenClaw Config", () => { }) test("resolveGateway returns null for disabled hook", () => { - const config: OpenClawConfig = testCoerce({ + const config: OpenClawConfig = unsafeTestValue({ enabled: true, gateways: { g: { type: "http", url: "https://example.com" } }, hooks: { diff --git a/src/openclaw/__tests__/reply-listener-discord.test.ts b/src/openclaw/__tests__/reply-listener-discord.test.ts index d84a99d81..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 = testCoerce(fetchMock) + globalThis.fetch = unsafeTestValue(fetchMock) const state = createState() @@ -109,7 +110,7 @@ describe("pollDiscordReplies", () => { ), ) .mockResolvedValueOnce(new Response(null, { status: 204 })) - globalThis.fetch = testCoerce(fetchMock) + 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 b5608df66..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, testCoerce("createBuiltinAgents")).mockResolvedValue({ + spyOn(agents, unsafeTestValue("createBuiltinAgents")).mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, oracle: { name: "oracle", prompt: "test", mode: "subagent" }, }) - spyOn(commandLoader, testCoerce("loadUserCommands")).mockResolvedValue({}) - spyOn(commandLoader, testCoerce("loadProjectCommands")).mockResolvedValue({}) - spyOn(commandLoader, testCoerce("loadOpencodeGlobalCommands")).mockResolvedValue({}) - spyOn(commandLoader, testCoerce("loadOpencodeProjectCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadUserCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadProjectCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadOpencodeGlobalCommands")).mockResolvedValue({}) + spyOn(commandLoader, unsafeTestValue("loadOpencodeProjectCommands")).mockResolvedValue({}) - spyOn(builtinCommands, testCoerce("loadBuiltinCommands")).mockReturnValue({}) + spyOn(builtinCommands, unsafeTestValue("loadBuiltinCommands")).mockReturnValue({}) - spyOn(skillLoader, testCoerce("loadUserSkills")).mockResolvedValue({}) - spyOn(skillLoader, testCoerce("loadProjectSkills")).mockResolvedValue({}) - spyOn(skillLoader, testCoerce("loadOpencodeGlobalSkills")).mockResolvedValue({}) - spyOn(skillLoader, testCoerce("loadOpencodeProjectSkills")).mockResolvedValue({}) - spyOn(skillLoader, testCoerce("discoverUserClaudeSkills")).mockResolvedValue([]) - spyOn(skillLoader, testCoerce("discoverProjectClaudeSkills")).mockResolvedValue([]) - spyOn(skillLoader, testCoerce("discoverOpencodeGlobalSkills")).mockResolvedValue([]) - spyOn(skillLoader, testCoerce("discoverOpencodeProjectSkills")).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, testCoerce("loadUserAgents")).mockReturnValue({}) - spyOn(agentLoader, testCoerce("loadProjectAgents")).mockReturnValue({}) - spyOn(agentLoader, testCoerce("loadOpencodeGlobalAgents")).mockReturnValue({}) - spyOn(agentLoader, testCoerce("loadOpencodeProjectAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadUserAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadProjectAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadOpencodeGlobalAgents")).mockReturnValue({}) + spyOn(agentLoader, unsafeTestValue("loadOpencodeProjectAgents")).mockReturnValue({}) - spyOn(mcpLoader, testCoerce("loadMcpConfigs")).mockResolvedValue({ servers: {} }) + spyOn(mcpLoader, unsafeTestValue("loadMcpConfigs")).mockResolvedValue({ servers: {}, loadedServers: [] }) setAdditionalAllowedMcpEnvVarsSpy = spyOn(mcpLoader, "setAdditionalAllowedMcpEnvVars").mockImplementation(() => {}) - spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).mockResolvedValue({ + spyOn(pluginLoader, unsafeTestValue("loadAllPluginComponents")).mockResolvedValue({ commands: {}, skills: {}, agents: {}, @@ -85,54 +86,57 @@ beforeEach(async () => { errors: [], }) - spyOn(mcpModule, testCoerce("createBuiltinMcps")).mockReturnValue({}) + spyOn(mcpModule, unsafeTestValue("createBuiltinMcps")).mockReturnValue({}) - spyOn(shared, testCoerce("log")).mockImplementation(() => {}) - spyOn(shared, testCoerce("fetchAvailableModels")).mockResolvedValue(new Set(["anthropic/claude-opus-4-7"])) - spyOn(shared, testCoerce("readConnectedProvidersCache")).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, testCoerce("getOpenCodeConfigPaths")).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, testCoerce("migrateAgentConfig")).mockImplementation((config: Record) => config) + spyOn(permissionCompat, unsafeTestValue("migrateAgentConfig")).mockImplementation((config: Record) => config) - spyOn(modelResolver, testCoerce("resolveModelWithFallback")).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(() => { - (testCoerce(agents.createBuiltinAgents))?.mockRestore?.() - ;(testCoerce(sisyphusJunior.createSisyphusJuniorAgentWithOverrides))?.mockRestore?.() - ;(testCoerce(commandLoader.loadUserCommands))?.mockRestore?.() - ;(testCoerce(commandLoader.loadProjectCommands))?.mockRestore?.() - ;(testCoerce(commandLoader.loadOpencodeGlobalCommands))?.mockRestore?.() - ;(testCoerce(commandLoader.loadOpencodeProjectCommands))?.mockRestore?.() - ;(testCoerce(builtinCommands.loadBuiltinCommands))?.mockRestore?.() - ;(testCoerce(skillLoader.loadUserSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.loadProjectSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.loadOpencodeGlobalSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.loadOpencodeProjectSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.discoverUserClaudeSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.discoverProjectClaudeSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.discoverOpencodeGlobalSkills))?.mockRestore?.() - ;(testCoerce(skillLoader.discoverOpencodeProjectSkills))?.mockRestore?.() - ;(testCoerce(agentLoader.loadUserAgents))?.mockRestore?.() - ;(testCoerce(agentLoader.loadProjectAgents))?.mockRestore?.() - ;(testCoerce(agentLoader.loadOpencodeGlobalAgents))?.mockRestore?.() - ;(testCoerce(agentLoader.loadOpencodeProjectAgents))?.mockRestore?.() - ;(testCoerce(mcpLoader.loadMcpConfigs))?.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() - ;(testCoerce(pluginLoader.loadAllPluginComponents))?.mockRestore?.() - ;(testCoerce(mcpModule.createBuiltinMcps))?.mockRestore?.() - ;(testCoerce(shared.log))?.mockRestore?.() - ;(testCoerce(shared.fetchAvailableModels))?.mockRestore?.() - ;(testCoerce(shared.readConnectedProvidersCache))?.mockRestore?.() - ;(testCoerce(configDir.getOpenCodeConfigPaths))?.mockRestore?.() - ;(testCoerce(permissionCompat.migrateAgentConfig))?.mockRestore?.() - ;(testCoerce(modelResolver.resolveModelWithFallback))?.mockRestore?.() - ;(testCoerce(agentPriorityOrder.reorderAgentsByPriority))?.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,7 +234,7 @@ describe("MCP env allowlist initialization", () => { describe("Plan agent demote behavior", () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { // #given - const createBuiltinAgentsMock = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -275,7 +279,7 @@ describe("Plan agent demote behavior", () => { test("assembles core agents first before priority reorder runs", async () => { // #given - const createBuiltinAgentsMock = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -285,7 +289,7 @@ describe("Plan agent demote behavior", () => { oracle: { name: "oracle", prompt: "test", mode: "subagent" }, atlas: { name: "atlas", prompt: "test", mode: "primary" }, }) - const reorderSpy = testCoerce(spyOn(agentPriorityOrder, "reorderAgentsByPriority")) + const reorderSpy = unsafeTestValue(spyOn(agentPriorityOrder, "reorderAgentsByPriority")) const pluginConfig = createPluginConfig({ sisyphus_agent: { planner_enabled: true, @@ -321,7 +325,7 @@ describe("Plan agent demote behavior", () => { test("backfills runtime core agent names when builtin configs omit name", async () => { // #given - const createBuiltinAgentsMock = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ @@ -485,7 +489,7 @@ describe("Plan agent demote behavior", () => { describe("Agent permission defaults", () => { test("hephaestus should allow task", async () => { // #given - const createBuiltinAgentsMock = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ @@ -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, testCoerce("resolveModelPipeline")).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, testCoerce("resolveModelPipeline")).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, testCoerce("resolveModelPipeline")).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 - ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() - spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).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 - ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() - spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).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 - ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() - spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).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 - ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockRestore?.() - spyOn(pluginLoader, testCoerce("loadAllPluginComponents")).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,14 +1359,14 @@ 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 = testCoerce<{ + 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" }, }) - ;(testCoerce<{ + ;(unsafeTestValue<{ mockReturnValue: (value: Record) => void }>(builtinCommands.loadBuiltinCommands)).mockReturnValue({ "start-work": { @@ -1404,7 +1412,7 @@ 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 = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void }>(agents.createBuiltinAgents) createBuiltinAgentsMock.mockResolvedValue({ @@ -1445,7 +1453,7 @@ 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 = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -1487,7 +1495,7 @@ 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 = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -1526,7 +1534,7 @@ 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 = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -1563,7 +1571,7 @@ describe("disable_omo_env pass-through", () => { test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => { //#given - const createBuiltinAgentsMock = testCoerce<{ + const createBuiltinAgentsMock = unsafeTestValue<{ mockResolvedValue: (value: Record) => void mock: { calls: unknown[][] } }>(agents.createBuiltinAgents) @@ -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 - ;(testCoerce(agentLoader.loadUserAgents)).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadUserAgents)).mockReturnValue({ "my-custom-agent": { description: "(user) global version", mode: "subagent", prompt: "I am the global agent", }, }) - ;(testCoerce(agentLoader.loadProjectAgents)).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 - ;(testCoerce(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(testCoerce(agentLoader.loadOpencodeProjectAgents)).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 - ;(testCoerce(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ + ;(unsafeTestValue(agentLoader.loadOpencodeGlobalAgents)).mockReturnValue({ "my-custom-agent": { description: "(opencode) global version", mode: "subagent", prompt: "I am the opencode global agent", }, }) - ;(testCoerce(agentLoader.loadProjectAgents)).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 - ;(testCoerce(pluginLoader.loadAllPluginComponents)).mockResolvedValue({ + ;(unsafeTestValue(pluginLoader.loadAllPluginComponents)).mockResolvedValue({ commands: {}, skills: {}, agents: { @@ -1735,7 +1743,7 @@ describe("Agent merge priority — project-local overrides global", () => { plugins: [], errors: [], }) - ;(testCoerce(agentLoader.loadUserAgents)).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 a22cc827f..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, testCoerce("loadMcpConfigs")).mockResolvedValue({ + loadMcpConfigsSpy = spyOn(mcpLoader, unsafeTestValue("loadMcpConfigs")).mockResolvedValue({ servers: {}, }) - createBuiltinMcpsSpy = spyOn(mcpModule, testCoerce("createBuiltinMcps")).mockReturnValue({}) - spyOn(shared, testCoerce("log")).mockImplementation(() => {}) + createBuiltinMcpsSpy = spyOn(mcpModule, unsafeTestValue("createBuiltinMcps")).mockReturnValue({}) + spyOn(shared, unsafeTestValue("log")).mockImplementation(() => {}) }) afterEach(() => { loadMcpConfigsSpy.mockRestore() createBuiltinMcpsSpy.mockRestore() - ;(testCoerce(shared.log))?.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: testCoerce(["playwright"]) }) + 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: testCoerce(["firecrawl", "exa"]) }) + 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: testCoerce(["plugin:custom"]) }) + 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 ecde2d9d0..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: testCoerce({ client: { tui: { showToast: async () => {} } } }), - pluginConfig: testCoerce((overrides?.pluginConfig ?? {})), + ctx: unsafeTestValue({ client: { tui: { showToast: async () => {} } } }), + pluginConfig: unsafeTestValue((overrides?.pluginConfig ?? {})), firstMessageVariantGate: { shouldOverride: () => overrides?.shouldOverride ?? false, markApplied: (sessionID: string) => { appliedSessions.push(sessionID) }, }, - hooks: testCoerce({ + hooks: unsafeTestValue({ stopContinuationGuard: null, backgroundNotificationHook: null, keywordDetector: null, diff --git a/src/plugin/event.model-fallback-2941.test.ts b/src/plugin/event.model-fallback-2941.test.ts index 0904d6bbf..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 testCoerce(input) + return unsafeTestValue(input) } function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] { - return testCoerce(ctx) + return unsafeTestValue(ctx) } function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] { - return testCoerce(config) + return unsafeTestValue(config) } function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] { - return testCoerce(ctx) + return unsafeTestValue(ctx) } function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] { - return testCoerce(config) + return unsafeTestValue(config) } function createEventHandlerManagers(): EventHandlerArgs["managers"] { - return testCoerce({ + return unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -45,13 +46,13 @@ function createEventHandlerManagers(): EventHandlerArgs["managers"] { } function createEventHandlerHooks(modelFallback: ReturnType): EventHandlerArgs["hooks"] { - return testCoerce({ + return unsafeTestValue({ modelFallback, }) } function createChatMessageHandlerHooks(modelFallback: ReturnType): ChatMessageHandlerArgs["hooks"] { - return testCoerce({ + return unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, diff --git a/src/plugin/event.model-fallback-pin-agent.test.ts b/src/plugin/event.model-fallback-pin-agent.test.ts index 059455655..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: testCoerce({ + ctx: unsafeTestValue({ directory: "/tmp", client: { session: sessionClient }, }), - pluginConfig: testCoerce((args?.pluginConfig ?? {})), + pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: testCoerce({ + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -68,7 +69,7 @@ describe("createEventHandler - model-fallback auto-continuation pins agent/model disconnectSession: async () => {}, }, }), - hooks: args?.hooks ?? (testCoerce({})), + 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 a31f4c2b3..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: testCoerce({ + ctx: unsafeTestValue({ directory: "/tmp", client: { session: { @@ -37,12 +38,12 @@ describe("createEventHandler - model fallback", () => { }, }, }), - pluginConfig: testCoerce((args?.pluginConfig ?? {})), + pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: testCoerce({ + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -51,7 +52,7 @@ describe("createEventHandler - model fallback", () => { disconnectSession: async () => {}, }, }), - hooks: args?.hooks ?? (testCoerce({})), + 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: testCoerce({ + ctx: unsafeTestValue({ client: { tui: { showToast: async () => ({}), }, }, }), - pluginConfig: testCoerce({}), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: testCoerce({ + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -358,19 +359,19 @@ describe("createEventHandler - model fallback", () => { const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig }) const chatMessageHandler = createChatMessageHandler({ - ctx: testCoerce({ + ctx: unsafeTestValue({ client: { tui: { showToast: async () => ({}), }, }, }), - pluginConfig: testCoerce({}), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: testCoerce({ + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, @@ -449,7 +450,7 @@ describe("createEventHandler - model fallback", () => { setupConnectedProviderCacheMocks() const eventHandler = createEventHandler({ - ctx: testCoerce({ + ctx: unsafeTestValue({ directory: "/tmp", client: { session: { @@ -464,12 +465,12 @@ describe("createEventHandler - model fallback", () => { }, }, }), - pluginConfig: testCoerce({}), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, - managers: testCoerce({ + managers: unsafeTestValue({ tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, @@ -478,13 +479,13 @@ describe("createEventHandler - model fallback", () => { disconnectSession: async () => {}, }, }), - hooks: testCoerce({ + hooks: unsafeTestValue({ modelFallback, }), }) const chatMessageHandler = createChatMessageHandler({ - ctx: testCoerce({ + ctx: unsafeTestValue({ client: { tui: { showToast: async ({ body }: { body: { title?: string } }) => { @@ -494,12 +495,12 @@ describe("createEventHandler - model fallback", () => { }, }, }), - pluginConfig: testCoerce({}), + pluginConfig: unsafeTestValue({}), firstMessageVariantGate: { shouldOverride: () => false, markApplied: () => {}, }, - hooks: testCoerce({ + hooks: unsafeTestValue({ modelFallback, stopContinuationGuard: null, keywordDetector: null, diff --git a/src/plugin/fallback.cliproxyapi-matrix.test.ts b/src/plugin/fallback.cliproxyapi-matrix.test.ts index 1497ab285..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,17 +19,17 @@ type HarnessContext = EventHandlerArgs["ctx"] & RuntimeFallbackPluginInput type HarnessEventInput = Parameters["eventHandler"]>[0] function asHarnessEventInput(input: unknown): HarnessEventInput { - return testCoerce(input) + return unsafeTestValue(input) } function asHarnessContext(ctx: unknown): HarnessContext { - return testCoerce(ctx) + return unsafeTestValue(ctx) } function createEventHandlerManagers( overrides: Record = {}, ): EventHandlerArgs["managers"] { - return testCoerce({ + return unsafeTestValue({ ...({} as EventHandlerArgs["managers"]), tmuxSessionManager: { onSessionCreated: async () => {}, @@ -41,7 +42,7 @@ function createEventHandlerManagers( function createEventHandlerHooks( overrides: Record, ): EventHandlerArgs["hooks"] { - return testCoerce({ + return unsafeTestValue({ ...({} as EventHandlerArgs["hooks"]), ...overrides, }) @@ -50,7 +51,7 @@ function createEventHandlerHooks( function createChatMessageHandlerHooks( overrides: Record, ): ChatMessageHandlerArgs["hooks"] { - return testCoerce({ + return unsafeTestValue({ ...({} as ChatMessageHandlerArgs["hooks"]), ...overrides, }) @@ -87,7 +88,7 @@ let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined function createPluginConfig(mode: HarnessMode) { - return testCoerce({ + return unsafeTestValue({ agents: { sisyphus: { fallback_models: CLIPROXYAPI_FALLBACKS, @@ -187,14 +188,14 @@ function createHarness(args: { timeout_seconds: args.sessionTimeoutMs ? 30 : 0, notify_on_fallback: false, }, - pluginConfig: testCoerce(pluginConfig), + pluginConfig: unsafeTestValue(pluginConfig), ...(args.sessionTimeoutMs ? { session_timeout_ms: args.sessionTimeoutMs } : {}), }) } const eventHandler = createEventHandler({ ctx, - pluginConfig: testCoerce(pluginConfig), + pluginConfig: unsafeTestValue(pluginConfig), firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, @@ -209,7 +210,7 @@ function createHarness(args: { const chatMessageHandler = createChatMessageHandler({ ctx, - pluginConfig: testCoerce(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 6c4abf3ea..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 = testCoerce({ +const mockContext = unsafeTestValue({ directory: "/tmp", client: { tui: { diff --git a/src/plugin/tool-execute-before.ulw-loop.test.ts b/src/plugin/tool-execute-before.ulw-loop.test.ts index 9df6c61df..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: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), - hooks: testCoerce[0]["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 ?? {} }) @@ -148,7 +149,7 @@ describe("tool.execute.before ultrawork oracle verification", () => { }) const beforeHandler = createToolExecuteBeforeHandler({ - ctx: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), + 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: testCoerce[0]["ctx"]>(createCtx(directory)), + ctx: unsafeTestValue[0]["ctx"]>(createCtx(directory)), hooks: {} as Parameters[0]["hooks"], }) const afterHandler = createToolExecuteAfterHandler({ - ctx: testCoerce[0]["ctx"]>(createCtx(directory)), + 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 eb9d255a2..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,7 +71,7 @@ describe("resolveUltraworkOverride", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return testCoerce[0]>({ + return unsafeTestValue[0]>({ agents: { [agentName]: { ultrawork }, }, @@ -139,7 +140,7 @@ describe("resolveUltraworkOverride", () => { test("should return null when agent has no ultrawork config", () => { //#given - const config = testCoerce[0]>({ + const config = unsafeTestValue[0]>({ agents: { sisyphus: { model: "anthropic/claude-sonnet-4-6" } }, }) const output = createOutput("ultrawork do something") @@ -278,7 +279,7 @@ describe("applyUltraworkModelOverrideOnMessage", () => { } function createConfig(agentName: string, ultrawork: { model?: string; variant?: string }) { - return testCoerce[0]>({ + return unsafeTestValue[0]>({ agents: { [agentName]: { ultrawork }, }, 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 080bec382..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 = testCoerce>(execFileSyncSpy.mock.calls) + 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/model-suggestion-retry.test.ts b/src/shared/model-suggestion-retry.test.ts index 35f9bb4dc..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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + 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(testCoerce(client), { + await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), { path: { id: "session-1" }, body: { agent: "multimodal-looker", diff --git a/src/testing/test-coerce-global.d.ts b/src/testing/test-coerce-global.d.ts deleted file mode 100644 index cc1e618be..000000000 --- a/src/testing/test-coerce-global.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare global { - function testCoerce(value: TValue): TValue -} - -export {} 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 1378b2162..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 = testCoerce({ + const manager = unsafeTestValue({ launch: mock(() => Promise.resolve({ id: "task-1", sessionID: null, @@ -28,7 +29,7 @@ describe("createBackgroundTask metadata", () => { })), getTask: mock(() => undefined), }) - const client = testCoerce({ + const client = unsafeTestValue({ session: { messages: mock(() => Promise.resolve({ data: [] })), }, diff --git a/src/tools/background-task/create-background-task.test.ts b/src/tools/background-task/create-background-task.test.ts index 89b378ac9..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,12 +22,12 @@ describe("createBackgroundTask", () => { })) const getTaskMock = mock() - const mockManager = testCoerce({ + const mockManager = unsafeTestValue({ launch: launchMock, getTask: getTaskMock, }) - const mockClient = testCoerce({ + const mockClient = unsafeTestValue({ session: { messages: mock(() => Promise.resolve({ data: [] })), }, diff --git a/src/tools/background-task/tools.test.ts b/src/tools/background-task/tools.test.ts index 0db5ce046..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,7 +67,7 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = testCoerce({ + const ctxWithCallId = unsafeTestValue({ ...mockContext, callID: "call-1", }) @@ -93,7 +94,7 @@ describe("background_output full_session", () => { const manager = createMockManager(task) const client = createMockClient({}) const tool = createBackgroundOutput(manager, client) - const ctxWithCallId = testCoerce({ + const ctxWithCallId = unsafeTestValue({ ...mockContext, callID: "call-1", }) @@ -387,7 +388,7 @@ describe("background_cancel", () => { // #given const task = createTask({ status: "running" }) const cancelled: string[] = [] - const manager = testCoerce({ + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string) => { @@ -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 = testCoerce({ + const manager = unsafeTestValue({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -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 = testCoerce({ + const manager = unsafeTestValue({ getTask: () => undefined, getAllDescendantTasks: () => [taskA, taskB], cancelTask: async (taskId: string) => { @@ -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 = testCoerce({ + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { @@ -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 = testCoerce({ + const manager = unsafeTestValue({ getTask: (id: string) => (id === task.id ? task : undefined), getAllDescendantTasks: () => [task], cancelTask: async (taskId: string, options?: unknown) => { diff --git a/src/tools/call-omo-agent/session-creator.test.ts b/src/tools/call-omo-agent/session-creator.test.ts index b3c3bc23c..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(testCoerce(args), testCoerce(toolContext), testCoerce(ctx)) + 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 = (testCoerce(createCalls[0]))?.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 e98d533dc..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 = testCoerce[0]>({ + const ctx = unsafeTestValue[0]>({ directory: contextDirectory, client: { session: { diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index 1ea512269..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, testCoerce(ctx), deps, undefined, spawnReservation) + await executeSync(args, toolContext, unsafeTestValue(ctx), deps, undefined, spawnReservation) //#then expect(spawnReservation.commit).toHaveBeenCalledTimes(1) diff --git a/src/tools/delegate-task/category-resolver.test.ts b/src/tools/delegate-task/category-resolver.test.ts index 9f7b72e44..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: testCoerce({}), - manager: testCoerce({}), + 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 247df5fdc..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 = testCoerce({ + const executorCtx = unsafeTestValue({ manager: { launch: async () => ({ id: "task_1", diff --git a/src/tools/delegate-task/metadata-model-unification.test.ts b/src/tools/delegate-task/metadata-model-unification.test.ts index b93824c21..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, testCoerce({ + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -92,7 +93,7 @@ describe("metadata model unification", () => { } await executeUnstableAgentTask( args, ctx, - testCoerce({ + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -126,7 +127,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_2", description: "continue", agent: "explore", @@ -153,7 +154,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -206,7 +207,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, testCoerce({ + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_1", description: "test", agent: "explore", @@ -236,7 +237,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - testCoerce({ + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -270,7 +271,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_2", description: "continue", agent: "explore", @@ -297,7 +298,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ data: [] }), @@ -381,7 +382,7 @@ describe("metadata model unification", () => { category: "visual-engineering", load_skills: [], run_in_background: true, subagent_type: "explore", } - await executeBackgroundTask(args, ctx, testCoerce({ + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_variant", description: "test", agent: "explore", @@ -411,7 +412,7 @@ describe("metadata model unification", () => { await executeUnstableAgentTask( args, ctx, - testCoerce({ + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -445,7 +446,7 @@ describe("metadata model unification", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_variant", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resume_variant", description: "continue", agent: "explore", @@ -472,7 +473,7 @@ describe("metadata model unification", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ 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 209dc19ac..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, testCoerce({ + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "explore", @@ -98,7 +99,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - testCoerce({ + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -136,7 +137,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", @@ -160,7 +161,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", @@ -187,7 +188,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { task_id: "ses_resumed_x", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resumed_y", description: "continue", agent: "explore", @@ -216,7 +217,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -246,7 +247,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -275,7 +276,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -309,7 +310,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), } - await executeSyncContinuation(args, ctx, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -368,7 +369,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { run_in_background: true, } - await executeBackgroundTask(args, ctx, testCoerce({ + await executeBackgroundTask(args, ctx, unsafeTestValue({ manager: { launch: async () => ({ id: "bg_abc123", description: "test", agent: "Sisyphus-Junior", @@ -402,7 +403,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { await executeUnstableAgentTask( args, ctx, - testCoerce({ + unsafeTestValue({ manager: { launch: async () => launchedTask, getTask: () => launchedTask, @@ -438,7 +439,7 @@ describe("taskId and backgroundTaskId metadata consistency", () => { load_skills: [], run_in_background: true, task_id: "ses_resume_title", } - await executeBackgroundContinuation(args, ctx, testCoerce({ + await executeBackgroundContinuation(args, ctx, unsafeTestValue({ manager: { resume: async () => ({ id: "bg_resume_title", description: "continue work", agent: "explore", @@ -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, testCoerce({ + await executeSyncContinuation(args, ctx, unsafeTestValue({ client: { session: { messages: async () => ({ @@ -500,8 +501,8 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }, } - const bgOutput = createBackgroundOutput(testCoerce(manager), testCoerce(client)) - await bgOutput.execute(testCoerce({ task_id: "bg_output_xyz" }), testCoerce(ctx)) + 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/task-schema.test.ts b/src/tools/delegate-task/task-schema.test.ts index c6747b834..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,7 +19,7 @@ function createDelegateTask(...args: Parameters { 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 = testCoerce[2]>({ + const executorContext = unsafeTestValue[2]>({ manager: mockManager, client: { session: { diff --git a/src/tools/hashline-edit/normalize-edits.test.ts b/src/tools/hashline-edit/normalize-edits.test.ts index 66ee57596..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,7 +52,7 @@ describe("normalizeHashlineEdits", () => { it("rejects legacy payload without op", () => { //#given - const input = testCoerce[0]>([{ type: "set_line", line: "2#VK", text: "updated" }]) diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index e362a6364..686cc839f 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -6,9 +6,10 @@ 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 testCoerce({ + return unsafeTestValue({ sessionID: "test", messageID: "test", agent: "test", diff --git a/src/tools/look-at/multimodal-agent-metadata.test.ts b/src/tools/look-at/multimodal-agent-metadata.test.ts index 3286c9482..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() - ;(testCoerce<{ mockRestore?: () => void }>(modelAvailability.fetchAvailableModels)).mockRestore?.() - ;(testCoerce<{ mockRestore?: () => void }>(connectedProvidersCache.readConnectedProvidersCache)).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 70908255f..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(testCoerce(client), "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(testCoerce(client), "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(testCoerce(client), "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(testCoerce(client), "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(testCoerce(client), "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(testCoerce(client), "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 d92a1ef60..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(testCoerce(args)) + 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(testCoerce(args)) + 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(testCoerce(args)) + 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 = testCoerce({ goal: "analyze" }) + 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 = testCoerce({ file_path: "/some/path.png" }) + const args = unsafeTestValue({ file_path: "/some/path.png" }) const error = validateArgs(args) expect(error).toContain("goal") expect(error).toContain("required") @@ -156,7 +157,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -193,7 +194,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -230,7 +231,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -291,7 +292,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -346,7 +347,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -395,7 +396,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -437,7 +438,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -486,7 +487,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -515,7 +516,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -539,7 +540,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -579,7 +580,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -632,7 +633,7 @@ describe("look-at tool", () => { }, } - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -701,7 +702,7 @@ 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(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -726,7 +727,7 @@ describe("look-at tool", () => { test("instructs agent to analyze attached image when image_data is provided", async () => { const { mockClient, captured } = captureLastPromptBody() - const tool = createLookAt(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) @@ -751,7 +752,7 @@ 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(testCoerce({ + const tool = createLookAt(unsafeTestValue({ client: mockClient, directory: "/project", })) diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts index 3bbcb1cd5..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 testCoerce>(0) + 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( - testCoerce<{ sendNotification: (m: string, p?: unknown) => void }>(client), + unsafeTestValue<{ sendNotification: (m: string, p?: unknown) => void }>(client), "sendNotification" ) diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 4f4bdf163..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(testCoerce[0]>(mockClient)) + 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(testCoerce[0]>(mockClient)) + 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(testCoerce[0]>(mockClient)) + 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(testCoerce[0]>(mockClient)) + 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(testCoerce[0]>(mockClient)) + 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 229a764fb..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: testCoerce(undefined) } + 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 4ec3e42eb..ccdfb0807 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -12,8 +12,6 @@ const { restoreModuleMocks } = installModuleMockLifecycle(mock) let environmentSnapshot: NodeJS.ProcessEnv = { ...process.env } let workingDirectorySnapshot = process.cwd() -globalThis.testCoerce = (value: TValue): TValue => value - function cleanupOmoCacheDir(cacheDir: string): void { rmSync(cacheDir, { recursive: true, force: true }) } diff --git a/test-support/unsafe-test-value.ts b/test-support/unsafe-test-value.ts new file mode 100644 index 000000000..b5fb2c6d2 --- /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 4bf254908..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[] = []; @@ -879,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; @@ -886,6 +888,7 @@ interface ToolCallEvent { } interface ToolResultEvent { + [key: string]: unknown; error?: string; output: string; tool_call_id: string; @@ -897,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, @@ -912,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, @@ -975,12 +1001,8 @@ async function runTestCase( } } - const toolCalls = testCoerce(events.filter( - (e) => e.type === "tool_call" - )); - const toolResults = testCoerce(events.filter( - (e) => e.type === "tool_result" - )); + 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 599dc4851..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"; @@ -574,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; @@ -581,6 +582,7 @@ interface ToolCallEvent { } interface ToolResultEvent { + [key: string]: unknown; error?: string; output: string; tool_call_id: string; @@ -592,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, @@ -605,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, @@ -668,12 +693,8 @@ async function runTestCase( } } - const toolCalls = testCoerce(events.filter( - (e) => e.type === "tool_call" - )); - const toolResults = testCoerce(events.filter( - (e) => e.type === "tool_result" - )); + 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)); From 730d72c9af198a2edc9b999b39ba6357ea6bd6c8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 16:30:00 +0900 Subject: [PATCH 32/35] test: require explicit unsafe test value type Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test-support/unsafe-test-value.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test-support/unsafe-test-value.ts b/test-support/unsafe-test-value.ts index b5fb2c6d2..faccd312e 100644 --- a/test-support/unsafe-test-value.ts +++ b/test-support/unsafe-test-value.ts @@ -1,5 +1,5 @@ export function unsafeTestValue(value: TValue): TValue -export function unsafeTestValue(value: unknown): TValue -export function unsafeTestValue(value: unknown): TValue { +export function unsafeTestValue(value: unknown): TValue +export function unsafeTestValue(value: unknown): TValue { return value as TValue } From 6035a551add2dd11a49c7fd8bfe19b8caaf19758 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 18:13:39 +0900 Subject: [PATCH 33/35] fix(background-agent): route session prompts by directory --- .../manager-session-permission.test.ts | 32 ++++++++ src/features/background-agent/manager.ts | 29 ++++--- src/features/background-agent/spawner.test.ts | 52 ++++++++++++ src/features/background-agent/spawner.ts | 37 ++++----- .../task-completion-cleanup.test.ts | 21 +++++ src/shared/index.ts | 1 + src/shared/session-route.ts | 80 +++++++++++++++++++ 7 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 src/shared/session-route.ts diff --git a/src/features/background-agent/manager-session-permission.test.ts b/src/features/background-agent/manager-session-permission.test.ts index fb34ee0aa..b50687442 100644 --- a/src/features/background-agent/manager-session-permission.test.ts +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -7,6 +7,38 @@ 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> = [] diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 180c0a5fe..ae963a28d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -14,9 +14,11 @@ 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" @@ -89,7 +91,6 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" - type OpencodeClient = PluginInput["client"] type ParentWakePromptContext = { @@ -781,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", { @@ -797,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) { @@ -1145,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, @@ -1165,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), @@ -1741,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 }) @@ -2152,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 @@ -2214,14 +2217,14 @@ The task was re-queued on a fallback model after a retryable failure. ...(resolvedTools ? { tools: resolvedTools } : {}), } try { - await this.client.session.promptAsync({ + 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, 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/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 0212a5e17..e68e3dfb7 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -12,6 +12,9 @@ type PromptAsyncCall = { noReply?: boolean parts?: unknown[] } + query?: { + directory: string + } } type FakeTimers = { @@ -274,6 +277,24 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) + 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 = { diff --git a/src/shared/index.ts b/src/shared/index.ts index dd637ce77..9b29c6fbe 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -71,6 +71,7 @@ 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/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)) +} From c740ed8acab252c3aea3f7ffb89d1be8a9620c93 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 18:14:17 +0900 Subject: [PATCH 34/35] fix(delegate-task): route sync prompts by directory --- .../delegate-task/sync-prompt-route.test.ts | 92 +++++++++++++++++++ src/tools/delegate-task/sync-prompt-sender.ts | 7 +- src/tools/delegate-task/sync-task.ts | 1 + 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/tools/delegate-task/sync-prompt-route.test.ts 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, From 75825eb9a65cb52991f0e65a0babdb14a03e3311 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 18:18:52 +0900 Subject: [PATCH 35/35] fix(todo-description-override): add OpenCode schema contract for string priorities --- src/hooks/todo-description-override/description.ts | 10 ++++++++++ src/hooks/todo-description-override/index.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) 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") + }) + }) + }) })