Merge branch 'dev' into fix/surface-subagent-quota-error

This commit is contained in:
Ivan Smetanin
2026-05-12 22:17:38 +01:00
committed by GitHub
225 changed files with 4552 additions and 2104 deletions
+6 -3
View File
@@ -42,10 +42,13 @@ jobs:
bun-version: "1.3.12" bun-version: "1.3.12"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Build plugin
run: bun run build
- name: Run tests - name: Run tests
run: bun run script/run-ci-tests.ts run: bun run script/run-ci-tests.ts
@@ -59,7 +62,7 @@ jobs:
bun-version: "1.3.12" bun-version: "1.3.12"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
@@ -84,7 +87,7 @@ jobs:
bun-version: "1.3.12" bun-version: "1.3.12"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
+4 -4
View File
@@ -41,7 +41,7 @@ jobs:
bun-version: "1.3.11" bun-version: "1.3.11"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
@@ -58,7 +58,7 @@ jobs:
bun-version: "1.3.11" bun-version: "1.3.11"
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
@@ -159,7 +159,7 @@ jobs:
run: npm install -g npm@latest run: npm install -g npm@latest
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
@@ -326,7 +326,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
@@ -21,7 +21,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install --frozen-lockfile
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
# Build local oh-my-opencode # Build local oh-my-opencode
- name: Build oh-my-opencode - name: Build oh-my-opencode
run: | run: |
bun install bun install --frozen-lockfile
bun run build bun run build
# Install OpenCode + configure local plugin + auth in single step # Install OpenCode + configure local plugin + auth in single step
+37 -30
View File
@@ -9,7 +9,7 @@
"@ast-grep/napi": "^0.41.1", "@ast-grep/napi": "^0.41.1",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.7.0", "@code-yeongyu/comment-checker": "^0.7.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.29.0",
"@opencode-ai/plugin": "^1.4.0", "@opencode-ai/plugin": "^1.4.0",
"@opencode-ai/sdk": "^1.4.0", "@opencode-ai/sdk": "^1.4.0",
"commander": "^14.0.2", "commander": "^14.0.2",
@@ -30,17 +30,17 @@
"zod": "^4.3.0", "zod": "^4.3.0",
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.17.15", "oh-my-opencode-darwin-arm64": "4.0.0",
"oh-my-opencode-darwin-x64": "3.17.15", "oh-my-opencode-darwin-x64": "4.0.0",
"oh-my-opencode-darwin-x64-baseline": "3.17.15", "oh-my-opencode-darwin-x64-baseline": "4.0.0",
"oh-my-opencode-linux-arm64": "3.17.15", "oh-my-opencode-linux-arm64": "4.0.0",
"oh-my-opencode-linux-arm64-musl": "3.17.15", "oh-my-opencode-linux-arm64-musl": "4.0.0",
"oh-my-opencode-linux-x64": "3.17.15", "oh-my-opencode-linux-x64": "4.0.0",
"oh-my-opencode-linux-x64-baseline": "3.17.15", "oh-my-opencode-linux-x64-baseline": "4.0.0",
"oh-my-opencode-linux-x64-musl": "3.17.15", "oh-my-opencode-linux-x64-musl": "4.0.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.17.15", "oh-my-opencode-linux-x64-musl-baseline": "4.0.0",
"oh-my-opencode-windows-x64": "3.17.15", "oh-my-opencode-windows-x64": "4.0.0",
"oh-my-opencode-windows-x64-baseline": "3.17.15", "oh-my-opencode-windows-x64-baseline": "4.0.0",
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4.0.0", "zod": "^4.0.0",
@@ -52,6 +52,13 @@
"@ast-grep/napi", "@ast-grep/napi",
"@code-yeongyu/comment-checker", "@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": { "packages": {
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="], "@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
@@ -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=="], "@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=="], "@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": ["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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+1 -1
View File
@@ -63,7 +63,7 @@ flowchart TB
Plan -->|"Read"| Orchestrator Plan -->|"Read"| Orchestrator
Orchestrator -->|"task(category=deep/quick/unspecified-*)"| Junior 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=explore)"| Explore
Orchestrator -->|"call_omo_agent(subagent_type=librarian)"| Librarian Orchestrator -->|"call_omo_agent(subagent_type=librarian)"| Librarian
Orchestrator -->|"task(category=visual-engineering, load_skills=[frontend-ui-ux])"| Frontend Orchestrator -->|"task(category=visual-engineering, load_skills=[frontend-ui-ux])"| Frontend
+9 -3
View File
@@ -34,7 +34,7 @@
"prepublishOnly": "bun run clean && bun run build", "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", "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", "typecheck": "tsc --noEmit",
"test": "bun test bin script src" "test": "bun run script/run-ci-tests.ts"
}, },
"keywords": [ "keywords": [
"opencode", "opencode",
@@ -60,7 +60,7 @@
"@ast-grep/napi": "^0.41.1", "@ast-grep/napi": "^0.41.1",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.7.0", "@code-yeongyu/comment-checker": "^0.7.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.29.0",
"@opencode-ai/plugin": "^1.4.0", "@opencode-ai/plugin": "^1.4.0",
"@opencode-ai/sdk": "^1.4.0", "@opencode-ai/sdk": "^1.4.0",
"commander": "^14.0.2", "commander": "^14.0.2",
@@ -93,7 +93,13 @@
"oh-my-opencode-windows-x64": "4.0.0", "oh-my-opencode-windows-x64": "4.0.0",
"oh-my-opencode-windows-x64-baseline": "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": [ "trustedDependencies": [
"@ast-grep/cli", "@ast-grep/cli",
"@ast-grep/napi", "@ast-grep/napi",
+11
View File
@@ -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")
})
})
+8
View File
@@ -3271,6 +3271,14 @@
"created_at": "2026-05-11T03:09:24Z", "created_at": "2026-05-11T03:09:24Z",
"repoId": 1108837393, "repoId": 1108837393,
"pullRequestNo": 3934 "pullRequestNo": 3934
},
{
"name": "jas32096",
"id": 5062225,
"comment_id": 4427423011,
"created_at": "2026-05-12T04:48:14Z",
"repoId": 1108837393,
"pullRequestNo": 3966
} }
] ]
} }
@@ -4,6 +4,7 @@ import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const HUNG_LEAD_SESSION_ID = "ses_999999999fffeeRegrTestHang0" const HUNG_LEAD_SESSION_ID = "ses_999999999fffeeRegrTestHang0"
@@ -16,11 +17,11 @@ function makeHangingClient(): {
hangCount.value += 1 hangCount.value += 1
return new Promise<never>(() => {}) return new Promise<never>(() => {})
} }
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: sessionGet, get: sessionGet,
}, },
} as unknown as PluginInput["client"] })
return { hangCount, client } return { hangCount, client }
} }
+1 -5
View File
@@ -26,10 +26,6 @@ declare function clearTimeout(timeout: number): void
type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"] type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"]
declare const Bun: {
readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string>
}
export interface BunInstallResult { export interface BunInstallResult {
success: boolean success: boolean
timedOut?: boolean timedOut?: boolean
@@ -50,7 +46,7 @@ function readProcessOutput(stream: ProcessOutputStream): Promise<string> {
return Promise.resolve("") return Promise.resolve("")
} }
return Bun.readableStreamToText(stream) return new Response(stream).text()
} }
function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void { function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void {
+6 -5
View File
@@ -3,6 +3,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test" import { afterEach, describe, expect, mock, test } from "bun:test"
import { fetchNpmDistTags } from "../config-manager" import { fetchNpmDistTags } from "../config-manager"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("fetchNpmDistTags", () => { describe("fetchNpmDistTags", () => {
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
@@ -13,12 +14,12 @@ describe("fetchNpmDistTags", () => {
test("returns dist-tags on success", async () => { test("returns dist-tags on success", async () => {
//#given //#given
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }),
} as Response) } as Response)
) as unknown as typeof fetch ))
//#when //#when
const result = await fetchNpmDistTags("oh-my-openagent") const result = await fetchNpmDistTags("oh-my-openagent")
@@ -29,7 +30,7 @@ describe("fetchNpmDistTags", () => {
test("returns null on network failure", async () => { test("returns null on network failure", async () => {
//#given //#given
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() => Promise.reject(new Error("Network error"))))
//#when //#when
const result = await fetchNpmDistTags("oh-my-openagent") const result = await fetchNpmDistTags("oh-my-openagent")
@@ -40,12 +41,12 @@ describe("fetchNpmDistTags", () => {
test("returns null on non-ok response", async () => { test("returns null on non-ok response", async () => {
//#given //#given
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: false, ok: false,
status: 404, status: 404,
} as Response) } as Response)
) as unknown as typeof fetch ))
//#when //#when
const result = await fetchNpmDistTags("oh-my-openagent") const result = await fetchNpmDistTags("oh-my-openagent")
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import * as configContext from "./config-context" import * as configContext from "./config-context"
import * as spawnHelpers from "../../shared/spawn-with-windows-hide" import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type OpenCodeBinaryModule = typeof import("./opencode-binary") type OpenCodeBinaryModule = typeof import("./opencode-binary")
@@ -92,12 +93,12 @@ describe("getOpenCodeVersion (installer)", () => {
}), }),
) )
const immediateSetTimeout = ((handler: TimerHandler) => { const immediateSetTimeout = unsafeTestValue<typeof globalThis.setTimeout>(((handler: TimerHandler) => {
if (typeof handler === "function") { if (typeof handler === "function") {
handler() handler()
} }
return 1 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(1)
}) as unknown as typeof globalThis.setTimeout }))
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout)
const result = await getOpenCodeVersion() const result = await getOpenCodeVersion()
@@ -124,12 +125,12 @@ describe("getOpenCodeVersion (installer)", () => {
}), }),
) )
const immediateSetTimeout = ((handler: TimerHandler) => { const immediateSetTimeout = unsafeTestValue<typeof globalThis.setTimeout>(((handler: TimerHandler) => {
if (typeof handler === "function") { if (typeof handler === "function") {
handler() handler()
} }
return 1 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(1)
}) as unknown as typeof globalThis.setTimeout }))
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout)
const result = await getOpenCodeVersion() const result = await getOpenCodeVersion()
@@ -3,6 +3,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test" import { afterEach, describe, expect, mock, test } from "bun:test"
import { getPluginNameWithVersion } from "../config-manager" import { getPluginNameWithVersion } from "../config-manager"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("getPluginNameWithVersion", () => { describe("getPluginNameWithVersion", () => {
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
@@ -13,12 +14,12 @@ describe("getPluginNameWithVersion", () => {
test("returns the canonical latest tag when current version matches latest", async () => { test("returns the canonical latest tag when current version matches latest", async () => {
//#given //#given
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }), json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }),
} as Response) } as Response)
) as unknown as typeof fetch ))
//#when //#when
const result = await getPluginNameWithVersion("3.13.1") const result = await getPluginNameWithVersion("3.13.1")
@@ -29,7 +30,7 @@ describe("getPluginNameWithVersion", () => {
test("preserves the canonical prerelease channel when fetch fails", async () => { test("preserves the canonical prerelease channel when fetch fails", async () => {
//#given //#given
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() => Promise.reject(new Error("Network error"))))
//#when //#when
const result = await getPluginNameWithVersion("3.14.0-beta.1") const result = await getPluginNameWithVersion("3.14.0-beta.1")
@@ -40,12 +41,12 @@ describe("getPluginNameWithVersion", () => {
test("returns the canonical bare package name for stable fallback", async () => { test("returns the canonical bare package name for stable fallback", async () => {
//#given //#given
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: false, ok: false,
status: 404, status: 404,
} as Response) } as Response)
) as unknown as typeof fetch ))
//#when //#when
const result = await getPluginNameWithVersion("3.13.1") const result = await getPluginNameWithVersion("3.13.1")
+7 -6
View File
@@ -5,6 +5,7 @@ import { join } from "node:path"
import { install } from "./install" import { install } from "./install"
import * as configManager from "./config-manager" import * as configManager from "./config-manager"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
// Mock console methods to capture output // Mock console methods to capture output
const mockConsoleLog = mock(() => {}) const mockConsoleLog = mock(() => {})
@@ -57,12 +58,12 @@ describe("install CLI - binary check behavior", () => {
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null)
// given mock npm fetch // given mock npm fetch
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ latest: "3.0.0" }), json: () => Promise.resolve({ latest: "3.0.0" }),
} as Response) } as Response)
) as unknown as typeof fetch ))
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
@@ -92,12 +93,12 @@ describe("install CLI - binary check behavior", () => {
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null) getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null)
// given mock npm fetch // given mock npm fetch
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ latest: "3.0.0" }), json: () => Promise.resolve({ latest: "3.0.0" }),
} as Response) } as Response)
) as unknown as typeof fetch ))
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
@@ -131,12 +132,12 @@ describe("install CLI - binary check behavior", () => {
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
// given mock npm fetch // given mock npm fetch
globalThis.fetch = mock(() => globalThis.fetch = unsafeTestValue<typeof fetch>(mock(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ latest: "3.0.0" }), json: () => Promise.resolve({ latest: "3.0.0" }),
} as Response) } as Response)
) as unknown as typeof fetch ))
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
+43 -42
View File
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os"
import type { RunContext } from "./types" import type { RunContext } from "./types"
import { _resetForTesting, setSessionAgent } from "../../features/claude-code-session-state" import { _resetForTesting, setSessionAgent } from "../../features/claude-code-session-state"
import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage" import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const testDirs: string[] = [] const testDirs: string[] = []
@@ -26,7 +27,7 @@ function createTempDir(): string {
function createMockContext(directory: string): RunContext { function createMockContext(directory: string): RunContext {
return { return {
client: { client: unsafeTestValue<RunContext["client"]>({
session: { session: {
todo: mock(() => Promise.resolve({ data: [] })), todo: mock(() => Promise.resolve({ data: [] })),
children: mock(() => Promise.resolve({ data: [] })), children: mock(() => Promise.resolve({ data: [] })),
@@ -39,7 +40,7 @@ function createMockContext(directory: string): RunContext {
})), })),
messages: mock(async () => ({ data: [] })), messages: mock(async () => ({ data: [] })),
}, },
} as unknown as RunContext["client"], }),
sessionID: "test-session", sessionID: "test-session",
directory, directory,
abortController: new AbortController(), abortController: new AbortController(),
@@ -155,17 +156,17 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "child-session" ctx.sessionID = "child-session"
setSessionAgent("child-session", "atlas") setSessionAgent("child-session", "atlas")
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "child-session" ? "root-session" : undefined, parentID: path.id === "child-session" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "child-session" data: path.id === "child-session"
? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }] ? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }]
: [], : [],
})) as unknown as RunContext["client"]["session"]["messages"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -187,13 +188,13 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "lineage-only-session" ctx.sessionID = "lineage-only-session"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "lineage-only-session" ? "root-session" : undefined, parentID: path.id === "lineage-only-session" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async () => ({ data: [] })) as unknown as RunContext["client"]["session"]["messages"] ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async () => ({ data: [] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -218,17 +219,17 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "mismatch-subagent-session" ctx.sessionID = "mismatch-subagent-session"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "mismatch-subagent-session" ? "root-session" : undefined, parentID: path.id === "mismatch-subagent-session" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "mismatch-subagent-session" data: path.id === "mismatch-subagent-session"
? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }]
: [], : [],
})) as unknown as RunContext["client"]["session"]["messages"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -253,17 +254,17 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "appended-mismatch-session" ctx.sessionID = "appended-mismatch-session"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "appended-mismatch-session" ? "root-session" : undefined, parentID: path.id === "appended-mismatch-session" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "appended-mismatch-session" data: path.id === "appended-mismatch-session"
? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }]
: [], : [],
})) as unknown as RunContext["client"]["session"]["messages"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -288,14 +289,14 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_appended_descendant" ctx.sessionID = "ses_appended_descendant"
ctx.client.session.get = mock(async () => { ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async () => {
throw new Error("session lookup failed") throw new Error("session lookup failed")
}) as unknown as RunContext["client"]["session"]["get"] }))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "ses_appended_descendant" data: path.id === "ses_appended_descendant"
? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }] ? [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }]
: [], : [],
})) as unknown as RunContext["client"]["session"]["messages"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -317,12 +318,12 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_direct_child" ctx.sessionID = "ses_direct_child"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "ses_direct_child" ? "ses_parent" : undefined, parentID: path.id === "ses_direct_child" ? "ses_parent" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -347,12 +348,12 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_direct_tracked" ctx.sessionID = "ses_direct_tracked"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: undefined, parentID: undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -374,9 +375,9 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_unknown_child" ctx.sessionID = "ses_unknown_child"
ctx.client.session.get = mock(async () => { ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async () => {
throw new Error("lineage unavailable") throw new Error("lineage unavailable")
}) as unknown as RunContext["client"]["session"]["get"] }))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -401,17 +402,17 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_direct_child" ctx.sessionID = "ses_direct_child"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "ses_direct_child" ? "ses_root_tracked" : undefined, parentID: path.id === "ses_direct_child" ? "ses_root_tracked" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "ses_direct_child" data: path.id === "ses_direct_child"
? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }] ? [{ info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }]
: [], : [],
})) as unknown as RunContext["client"]["session"]["messages"] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -437,20 +438,20 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_child_after_compaction" ctx.sessionID = "ses_child_after_compaction"
setSessionAgent("ses_child_after_compaction", "atlas") setSessionAgent("ses_child_after_compaction", "atlas")
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "ses_child_after_compaction" ? "root-session" : undefined, parentID: path.id === "ses_child_after_compaction" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "ses_child_after_compaction" data: path.id === "ses_child_after_compaction"
? [ ? [
{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }, { info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } },
{ info: { agent: "compaction", 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") const { checkCompletionConditions } = await import("./completion")
@@ -472,13 +473,13 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_sqlite_descendant" ctx.sessionID = "ses_sqlite_descendant"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "ses_sqlite_descendant" ? "root-session" : undefined, parentID: path.id === "ses_sqlite_descendant" ? "root-session" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: path.id === "ses_sqlite_descendant" data: path.id === "ses_sqlite_descendant"
? [ ? [
{ id: "msg_0001", info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4", time: { created: 100 } } }, { id: "msg_0001", info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4", time: { created: 100 } } },
@@ -486,7 +487,7 @@ describe("checkCompletionConditions continuation coverage", () => {
{ id: "msg_0002", info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4", time: { created: 100 } } }, { 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") const { checkCompletionConditions } = await import("./completion")
@@ -512,13 +513,13 @@ describe("checkCompletionConditions continuation coverage", () => {
const ctx = createMockContext(directory) const ctx = createMockContext(directory)
ctx.sessionID = "ses_appended_child" ctx.sessionID = "ses_appended_child"
setSessionAgent("ses_appended_child", "atlas") setSessionAgent("ses_appended_child", "atlas")
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({ ctx.client.session.get = unsafeTestValue<RunContext["client"]["session"]["get"]>(mock(async ({ path }: { path: { id: string } }) => ({
data: { data: {
id: path.id, id: path.id,
parentID: path.id === "ses_appended_child" ? "ses_root_tracked" : undefined, parentID: path.id === "ses_appended_child" ? "ses_root_tracked" : undefined,
}, },
})) as unknown as RunContext["client"]["session"]["get"] })))
ctx.client.session.messages = mock(async () => ({ data: [] })) as unknown as RunContext["client"]["session"]["messages"] ctx.client.session.messages = unsafeTestValue<RunContext["client"]["session"]["messages"]>(mock(async () => ({ data: [] })))
const { checkCompletionConditions } = await import("./completion") const { checkCompletionConditions } = await import("./completion")
@@ -1,5 +1,6 @@
import { describe, it, expect, mock, spyOn } from "bun:test" import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, ChildSession, SessionStatus } from "./types" import type { RunContext, ChildSession, SessionStatus } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const createMockContext = (overrides: { const createMockContext = (overrides: {
childrenBySession?: Record<string, ChildSession[]> childrenBySession?: Record<string, ChildSession[]>
@@ -13,7 +14,7 @@ const createMockContext = (overrides: {
} = overrides } = overrides
return { return {
client: { client: unsafeTestValue<RunContext["client"]>({
session: { session: {
todo: mock(() => Promise.resolve({ data: [] })), todo: mock(() => Promise.resolve({ data: [] })),
children: mock((opts: { path: { id: string } }) => children: mock((opts: { path: { id: string } }) =>
@@ -21,7 +22,7 @@ const createMockContext = (overrides: {
), ),
status: mock(() => Promise.resolve({ data: statuses })), status: mock(() => Promise.resolve({ data: statuses })),
}, },
} as unknown as RunContext["client"], }),
sessionID: "test-session", sessionID: "test-session",
directory: "/test", directory: "/test",
abortController: new AbortController(), abortController: new AbortController(),
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, mock, spyOn } from "bun:test" import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const createMockContext = (overrides: { const createMockContext = (overrides: {
todo?: Todo[] todo?: Todo[]
@@ -13,7 +14,7 @@ const createMockContext = (overrides: {
} = overrides } = overrides
return { return {
client: { client: unsafeTestValue<RunContext["client"]>({
session: { session: {
todo: mock(() => Promise.resolve({ data: todo })), todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) => children: mock((opts: { path: { id: string } }) =>
@@ -21,7 +22,7 @@ const createMockContext = (overrides: {
), ),
status: mock(() => Promise.resolve({ data: statuses })), status: mock(() => Promise.resolve({ data: statuses })),
}, },
} as unknown as RunContext["client"], }),
sessionID: "test-session", sessionID: "test-session",
directory: "/test", directory: "/test",
abortController: new AbortController(), abortController: new AbortController(),
+18 -17
View File
@@ -2,6 +2,7 @@ const { describe, it, expect, spyOn } = require("bun:test")
import type { RunContext } from "./types" import type { RunContext } from "./types"
import { createEventState } from "./events" import { createEventState } from "./events"
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers" import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const createMockContext = (sessionID: string = "test-session"): RunContext => ({ const createMockContext = (sessionID: string = "test-session"): RunContext => ({
sessionID, sessionID,
@@ -23,7 +24,7 @@ describe("handleSessionStatus", () => {
} }
//#when - handleSessionStatus called with idle status //#when - handleSessionStatus called with idle status
handleSessionStatus(ctx, payload as any, state) handleSessionStatus(ctx, unsafeTestValue(payload), state)
//#then - state.mainSessionIdle === true //#then - state.mainSessionIdle === true
expect(state.mainSessionIdle).toBe(true) expect(state.mainSessionIdle).toBe(true)
@@ -44,7 +45,7 @@ describe("handleSessionStatus", () => {
} }
//#when - handleSessionStatus called with busy status //#when - handleSessionStatus called with busy status
handleSessionStatus(ctx, payload as any, state) handleSessionStatus(ctx, unsafeTestValue(payload), state)
//#then - state.mainSessionIdle === false //#then - state.mainSessionIdle === false
expect(state.mainSessionIdle).toBe(false) expect(state.mainSessionIdle).toBe(false)
@@ -65,7 +66,7 @@ describe("handleSessionStatus", () => {
} }
//#when - handleSessionStatus called with different session ID //#when - handleSessionStatus called with different session ID
handleSessionStatus(ctx, payload as any, state) handleSessionStatus(ctx, unsafeTestValue(payload), state)
//#then - state.mainSessionIdle remains unchanged //#then - state.mainSessionIdle remains unchanged
expect(state.mainSessionIdle).toBe(true) expect(state.mainSessionIdle).toBe(true)
@@ -86,7 +87,7 @@ describe("handleSessionStatus", () => {
} }
//#when - handleSessionStatus called with camelCase sessionId //#when - handleSessionStatus called with camelCase sessionId
handleSessionStatus(ctx, payload as any, state) handleSessionStatus(ctx, unsafeTestValue(payload), state)
//#then - state.mainSessionIdle === true //#then - state.mainSessionIdle === true
expect(state.mainSessionIdle).toBe(true) expect(state.mainSessionIdle).toBe(true)
@@ -114,7 +115,7 @@ describe("handleMessagePartUpdated", () => {
} }
//#when //#when
handleMessagePartUpdated(ctx, payload as any, state) handleMessagePartUpdated(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.hasReceivedMeaningfulWork).toBe(true) expect(state.hasReceivedMeaningfulWork).toBe(true)
@@ -142,7 +143,7 @@ describe("handleMessagePartUpdated", () => {
} }
//#when //#when
handleMessagePartUpdated(ctx, payload as any, state) handleMessagePartUpdated(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.hasReceivedMeaningfulWork).toBe(false) expect(state.hasReceivedMeaningfulWork).toBe(false)
@@ -170,7 +171,7 @@ describe("handleMessagePartUpdated", () => {
} }
//#when //#when
handleMessagePartUpdated(ctx, payload as any, state) handleMessagePartUpdated(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.currentTool).toBe("read") expect(state.currentTool).toBe("read")
@@ -200,7 +201,7 @@ describe("handleMessagePartUpdated", () => {
} }
//#when //#when
handleMessagePartUpdated(ctx, payload as any, state) handleMessagePartUpdated(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.currentTool).toBeNull() expect(state.currentTool).toBeNull()
@@ -225,7 +226,7 @@ describe("handleMessagePartUpdated", () => {
} }
//#when //#when
handleMessagePartUpdated(ctx, payload as any, state) handleMessagePartUpdated(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.hasReceivedMeaningfulWork).toBe(true) expect(state.hasReceivedMeaningfulWork).toBe(true)
@@ -243,7 +244,7 @@ describe("handleMessagePartUpdated", () => {
handleMessageUpdated( handleMessageUpdated(
ctx, ctx,
{ unsafeTestValue({
type: "message.updated", type: "message.updated",
properties: { properties: {
info: { info: {
@@ -254,7 +255,7 @@ describe("handleMessagePartUpdated", () => {
modelID: "claude-sonnet-4-6", modelID: "claude-sonnet-4-6",
}, },
}, },
} as any, }),
state, state,
) )
state.messageStartedAtById["msg_1"] = 1000 state.messageStartedAtById["msg_1"] = 1000
@@ -262,7 +263,7 @@ describe("handleMessagePartUpdated", () => {
// when // when
handleMessagePartUpdated( handleMessagePartUpdated(
ctx, ctx,
{ unsafeTestValue({
type: "message.part.updated", type: "message.part.updated",
properties: { properties: {
part: { part: {
@@ -274,13 +275,13 @@ describe("handleMessagePartUpdated", () => {
time: { end: 1 }, time: { end: 1 },
}, },
}, },
} as any, }),
state, state,
) )
handleMessagePartUpdated( handleMessagePartUpdated(
ctx, ctx,
{ unsafeTestValue({
type: "message.part.updated", type: "message.part.updated",
properties: { properties: {
part: { part: {
@@ -292,7 +293,7 @@ describe("handleMessagePartUpdated", () => {
time: { end: 2 }, time: { end: 2 },
}, },
}, },
} as any, }),
state, state,
) )
@@ -323,7 +324,7 @@ describe("handleTuiToast", () => {
} }
//#when //#when
handleTuiToast(ctx, payload as any, state) handleTuiToast(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.mainSessionError).toBe(true) expect(state.mainSessionError).toBe(true)
@@ -344,7 +345,7 @@ describe("handleTuiToast", () => {
} }
//#when //#when
handleTuiToast(ctx, payload as any, state) handleTuiToast(ctx, unsafeTestValue(payload), state)
//#then //#then
expect(state.mainSessionError).toBe(false) expect(state.mainSessionError).toBe(false)
+9 -8
View File
@@ -7,6 +7,7 @@ import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hid
import type { OpencodeClient } from "./types" import type { OpencodeClient } from "./types"
import * as originalSdk from "@opencode-ai/sdk" import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils" import * as originalPortUtils from "../../shared/port-utils"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockServerClose = mock(() => {}) const mockServerClose = mock(() => {})
const mockCreateOpencode = mock(() => const mockCreateOpencode = mock(() =>
@@ -56,14 +57,14 @@ function createMockWriteStream(): MockWriteStream {
const createMockClient = ( const createMockClient = (
getResult?: { error?: unknown; data?: { id: string } } getResult?: { error?: unknown; data?: { id: string } }
): OpencodeClient => ({ ): OpencodeClient => (unsafeTestValue<OpencodeClient>({
session: { session: {
get: mock((opts: { path: { id: string } }) => get: mock((opts: { path: { id: string } }) =>
Promise.resolve(getResult ?? { data: { id: opts.path.id } }) Promise.resolve(getResult ?? { data: { id: opts.path.id } })
), ),
create: mock(() => Promise.resolve({ data: { id: "new-session-id" } })), create: mock(() => Promise.resolve({ data: { id: "new-session-id" } })),
}, },
} as unknown as OpencodeClient) }))
describe("integration: --json mode", () => { describe("integration: --json mode", () => {
it("emits valid RunResult JSON to stdout", () => { it("emits valid RunResult JSON to stdout", () => {
@@ -78,8 +79,8 @@ describe("integration: --json mode", () => {
summary: "Test summary", summary: "Test summary",
} }
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
// when // when
@@ -103,8 +104,8 @@ describe("integration: --json mode", () => {
const mockStdout = createMockWriteStream() const mockStdout = createMockWriteStream()
const mockStderr = createMockWriteStream() const mockStderr = createMockWriteStream()
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
manager.redirectToStderr() manager.redirectToStderr()
@@ -272,8 +273,8 @@ describe("integration: option combinations", () => {
summary: "Test completed", summary: "Test completed",
} }
const jsonManager = createJsonOutputManager({ const jsonManager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
jsonManager.redirectToStderr() jsonManager.redirectToStderr()
spawnSpy.mockClear() spawnSpy.mockClear()
+13 -12
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from "bun:test" import { describe, it, expect, beforeEach } from "bun:test"
import type { RunResult } from "./types" import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output" import { createJsonOutputManager } from "./json-output"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
interface MockWriteStream { interface MockWriteStream {
write: (chunk: string) => boolean write: (chunk: string) => boolean
@@ -31,8 +32,8 @@ describe("createJsonOutputManager", () => {
it("causes stdout writes to go to stderr", () => { it("causes stdout writes to go to stderr", () => {
// given // given
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
manager.redirectToStderr() manager.redirectToStderr()
@@ -49,8 +50,8 @@ describe("createJsonOutputManager", () => {
it("reverses the redirect", () => { it("reverses the redirect", () => {
// given // given
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
manager.redirectToStderr() manager.redirectToStderr()
@@ -75,8 +76,8 @@ describe("createJsonOutputManager", () => {
summary: "Test summary", summary: "Test summary",
} }
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
// when // when
@@ -98,8 +99,8 @@ describe("createJsonOutputManager", () => {
summary: "Test summary", summary: "Test summary",
} }
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
// when // when
@@ -126,8 +127,8 @@ describe("createJsonOutputManager", () => {
summary: "Test", summary: "Test",
} }
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
manager.redirectToStderr() manager.redirectToStderr()
@@ -148,8 +149,8 @@ describe("createJsonOutputManager", () => {
it("work correctly", () => { it("work correctly", () => {
// given // given
const manager = createJsonOutputManager({ const manager = createJsonOutputManager({
stdout: mockStdout as unknown as NodeJS.WriteStream, stdout: unsafeTestValue<NodeJS.WriteStream>(mockStdout),
stderr: mockStderr as unknown as NodeJS.WriteStream, stderr: unsafeTestValue<NodeJS.WriteStream>(mockStderr),
}) })
// when // when
+9 -8
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:te
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { createEventState } from "./events" import { createEventState } from "./events"
import { pollForCompletion } from "./poll-for-completion" import { pollForCompletion } from "./poll-for-completion"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const createMockContext = (overrides: { const createMockContext = (overrides: {
todo?: Todo[] todo?: Todo[]
@@ -15,7 +16,7 @@ const createMockContext = (overrides: {
} = overrides } = overrides
return { return {
client: { client: unsafeTestValue<RunContext["client"]>({
session: { session: {
todo: mock(() => Promise.resolve({ data: todo })), todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) => children: mock((opts: { path: { id: string } }) =>
@@ -23,7 +24,7 @@ const createMockContext = (overrides: {
), ),
status: mock(() => Promise.resolve({ data: statuses })), status: mock(() => Promise.resolve({ data: statuses })),
}, },
} as unknown as RunContext["client"], }),
sessionID: "test-session", sessionID: "test-session",
directory: "/test", directory: "/test",
abortController: new AbortController(), abortController: new AbortController(),
@@ -124,7 +125,7 @@ describe("pollForCompletion", () => {
let todoCallCount = 0 let todoCallCount = 0
let busyInserted = false let busyInserted = false
;(ctx.client.session as any).todo = mock(async () => { ;(unsafeTestValue(ctx.client.session)).todo = mock(async () => {
todoCallCount++ todoCallCount++
if (todoCallCount === 1 && !busyInserted) { if (todoCallCount === 1 && !busyInserted) {
busyInserted = true busyInserted = true
@@ -133,10 +134,10 @@ describe("pollForCompletion", () => {
} }
return { data: [] } return { data: [] }
}) })
;(ctx.client.session as any).children = mock(() => ;(unsafeTestValue(ctx.client.session)).children = mock(() =>
Promise.resolve({ data: [] }) Promise.resolve({ data: [] })
) )
;(ctx.client.session as any).status = mock(() => ;(unsafeTestValue(ctx.client.session)).status = mock(() =>
Promise.resolve({ data: {} }) Promise.resolve({ data: {} })
) )
@@ -322,17 +323,17 @@ describe("pollForCompletion", () => {
const abortController = new AbortController() const abortController = new AbortController()
let pollTick = 0 let pollTick = 0
;(ctx.client.session as any).todo = mock(async () => { ;(unsafeTestValue(ctx.client.session)).todo = mock(async () => {
pollTick++ pollTick++
if (pollTick === 2) { if (pollTick === 2) {
eventState.currentTool = "task" eventState.currentTool = "task"
} }
return { data: [] } return { data: [] }
}) })
;(ctx.client.session as any).children = mock(() => ;(unsafeTestValue(ctx.client.session)).children = mock(() =>
Promise.resolve({ data: [] }) Promise.resolve({ data: [] })
) )
;(ctx.client.session as any).status = mock(() => ;(unsafeTestValue(ctx.client.session)).status = mock(() =>
Promise.resolve({ data: {} }) Promise.resolve({ data: {} })
) )
+3 -2
View File
@@ -1,4 +1,5 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { resolveSession } from "./session-resolver"; import { resolveSession } from "./session-resolver";
@@ -10,7 +11,7 @@ const createMockClient = (overrides: {
} = {}): OpencodeClient => { } = {}): OpencodeClient => {
const { getResult, createResults = [] } = overrides const { getResult, createResults = [] } = overrides
let createCallIndex = 0 let createCallIndex = 0
return { return unsafeTestValue<OpencodeClient>({
session: { session: {
get: mock((opts: { path: { id: string } }) => get: mock((opts: { path: { id: string } }) =>
Promise.resolve(getResult ?? { data: { id: opts.path.id } }) Promise.resolve(getResult ?? { data: { id: opts.path.id } })
@@ -22,7 +23,7 @@ const createMockClient = (overrides: {
return Promise.resolve(result) return Promise.resolve(result)
}), }),
}, },
} as unknown as OpencodeClient })
} }
describe("resolveSession", () => { describe("resolveSession", () => {
+4 -3
View File
@@ -2,6 +2,7 @@
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output" import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createLocalDate(hours: number, minutes: number, seconds: number): Date { function createLocalDate(hours: number, minutes: number, seconds: number): Date {
return new Date(2026, 1, 19, hours, minutes, seconds) return new Date(2026, 1, 19, hours, minutes, seconds)
@@ -87,7 +88,7 @@ describe("createTimestampedStdoutController", () => {
it("prefixes stdout writes when enabled", () => { it("prefixes stdout writes when enabled", () => {
// given // given
const stdout = createMockWriteStream() const stdout = createMockWriteStream()
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) const controller = createTimestampedStdoutController(unsafeTestValue<NodeJS.WriteStream>(stdout))
// when // when
controller.enable() controller.enable()
@@ -101,7 +102,7 @@ describe("createTimestampedStdoutController", () => {
it("restores original write function", () => { it("restores original write function", () => {
// given // given
const stdout = createMockWriteStream() const stdout = createMockWriteStream()
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) const controller = createTimestampedStdoutController(unsafeTestValue<NodeJS.WriteStream>(stdout))
controller.enable() controller.enable()
// when // when
@@ -118,7 +119,7 @@ describe("createTimestampedStdoutController", () => {
it("supports Uint8Array chunks and encoding", () => { it("supports Uint8Array chunks and encoding", () => {
// given // given
const stdout = createMockWriteStream() const stdout = createMockWriteStream()
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) const controller = createTimestampedStdoutController(unsafeTestValue<NodeJS.WriteStream>(stdout))
// when // when
controller.enable() controller.enable()
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("buildBackgroundTaskNotificationText", () => { describe("buildBackgroundTaskNotificationText", () => {
describe("#given one task still running after a completed task notification", () => { describe("#given one task still running after a completed task notification", () => {
@@ -134,7 +135,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
const notification = buildBackgroundTaskNotificationText({ const notification = buildBackgroundTaskNotificationText({
task: { task: {
id: "bg_abc123", id: "bg_abc123",
description: undefined as unknown as string, description: unsafeTestValue<string>(undefined),
status: "completed", status: "completed",
}, },
duration: "5s", duration: "5s",
@@ -142,8 +143,8 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
allComplete: true, allComplete: true,
remainingCount: 0, remainingCount: 0,
completedTasks: [ completedTasks: [
{ id: "bg_abc123", description: undefined as unknown as string, status: "completed" }, { id: "bg_abc123", description: unsafeTestValue<string>(undefined), status: "completed" },
{ id: "bg_def456", description: undefined as unknown as string, status: "completed" }, { id: "bg_def456", description: unsafeTestValue<string>(undefined), status: "completed" },
], ],
}) })
@@ -230,7 +231,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
const notification = buildBackgroundTaskNotificationText({ const notification = buildBackgroundTaskNotificationText({
task: { task: {
id: "bg_xyz789", id: "bg_xyz789",
description: undefined as unknown as string, description: unsafeTestValue<string>(undefined),
status: "completed", status: "completed",
}, },
duration: "3s", duration: "3s",
@@ -12,6 +12,7 @@ import {
setCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint" } from "../../shared/compaction-agent-config-checkpoint"
import { getCompactionPartStorageDir } from "../../shared/compaction-marker" import { getCompactionPartStorageDir } from "../../shared/compaction-marker"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("isCompactionAgent", () => { describe("isCompactionAgent", () => {
describe("#given agent name variations", () => { describe("#given agent name variations", () => {
@@ -49,7 +50,7 @@ describe("isCompactionAgent", () => {
test("returns false for null", () => { test("returns false for null", () => {
// when // when
const result = isCompactionAgent(null as unknown as string) const result = isCompactionAgent(unsafeTestValue<string>(null))
// then // then
expect(result).toBe(false) expect(result).toBe(false)
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
import type { BackgroundTaskConfig } from "../../config/schema" import type { BackgroundTaskConfig } from "../../config/schema"
import { BackgroundManager } from "./manager" import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createManager(config?: BackgroundTaskConfig): BackgroundManager { function createManager(config?: BackgroundTaskConfig): BackgroundManager {
const client = { const client = {
@@ -16,12 +17,12 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
}, },
} }
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: config }) const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }), config: config })
const testManager = manager as unknown as { const testManager = unsafeTestValue<{
enqueueNotificationForParent: (sessionId: string, fn: () => Promise<void>) => Promise<void> enqueueNotificationForParent: (sessionId: string, fn: () => Promise<void>) => Promise<void>
notifyParentSession: (task: BackgroundTask) => Promise<void> notifyParentSession: (task: BackgroundTask) => Promise<void>
tasks: Map<string, BackgroundTask> tasks: Map<string, BackgroundTask>
} }>(manager)
testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => { testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => {
await fn() await fn()
@@ -32,7 +33,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
} }
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> { function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks return (unsafeTestValue<{ tasks: Map<string, BackgroundTask> }>(manager)).tasks
} }
async function flushAsyncWork() { async function flushAsyncWork() {
@@ -4,8 +4,41 @@ import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager" import { BackgroundManager } from "./manager"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("BackgroundManager session permission", () => { describe("BackgroundManager session permission", () => {
test("passes parent directory route when prompting the child session", async () => {
// given
const promptCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async () => ({ data: { id: "ses_child" } }),
promptAsync: async (input: Record<string, unknown>) => {
promptCalls.push(input)
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ 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 () => { test("passes query directory when loading the parent session", async () => {
// given // given
const getCalls: Array<Record<string, unknown>> = [] const getCalls: Array<Record<string, unknown>> = []
@@ -21,7 +54,7 @@ describe("BackgroundManager session permission", () => {
}, },
} }
const directory = tmpdir() const directory = tmpdir()
const manager = new BackgroundManager({ pluginContext: { client, directory } as unknown as PluginInput }) const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory }) })
// when // when
await manager.launch({ await manager.launch({
@@ -62,7 +95,7 @@ describe("BackgroundManager session permission", () => {
abort: async () => ({}), abort: async () => ({}),
}, },
} }
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput }) const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }) })
// when // when
await manager.launch({ await manager.launch({
@@ -345,6 +345,47 @@ describe("BackgroundManager pollRunningTasks", () => {
expect(task.status).toBe("completed") expect(task.status).toBe("completed")
expect(todoCallCount).toBe(0) 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", () => { describe("#given a running task whose session status is busy", () => {
@@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown() 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 () => { test("completes task on session.status idle after todo-continuation finishes", async () => {
//#given //#given
const sessionID = "ses-status-idle-after-todo-continuation" 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) 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", () => { test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate //#given - a running task with stale lastUpdate
const client = { const client = {
+48 -186
View File
@@ -14,10 +14,13 @@ import {
getAgentToolRestrictions, getAgentToolRestrictions,
normalizePromptTools, normalizePromptTools,
normalizeSDKResponse, normalizeSDKResponse,
promptWithModelSuggestionRetry,
resolveInheritedPromptTools, resolveInheritedPromptTools,
createInternalAgentTextPart, createInternalAgentTextPart,
messagesInDirectory,
promptAsyncInDirectory,
promptWithRetryInDirectory,
} from "../../shared" } from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { setSessionTools } from "../../shared/session-tools-store" import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -88,8 +91,6 @@ import {
resolveSubagentSpawnContext, resolveSubagentSpawnContext,
type SubagentSpawnContext, type SubagentSpawnContext,
} from "./subagent-spawn-limits" } from "./subagent-spawn-limits"
import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
type ParentWakePromptContext = { type ParentWakePromptContext = {
@@ -99,15 +100,6 @@ type ParentWakePromptContext = {
tools?: Record<string, boolean> tools?: Record<string, boolean>
} }
type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
}
type SessionStatusInfo = { type?: string }
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
interface MessagePartInfo { interface MessagePartInfo {
id?: string id?: string
sessionID?: string sessionID?: string
@@ -118,7 +110,7 @@ interface MessagePartInfo {
interface EventProperties { interface EventProperties {
sessionID?: string sessionID?: string
info?: { id?: string } info?: { id?: string; sessionID?: string }
[key: string]: unknown [key: string]: unknown
} }
@@ -229,8 +221,6 @@ export class BackgroundManager {
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map() private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map() private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map() private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private observedOutputSessions: Set<string> = new Set() private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map() private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number> private rootDescendantCounts: Map<string, number>
@@ -792,10 +782,10 @@ The fallback retry session is now created and can be inspected directly.
parts: [createInternalAgentTextPart(input.prompt)], parts: [createInternalAgentTextPart(input.prompt)],
} }
promptWithModelSuggestionRetry(this.client, { promptWithRetryInDirectory(this.client, {
path: { id: sessionID }, path: { id: sessionID },
body: promptBody, 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) // Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", { log("[background-agent] Agent not found, retrying with fallback agent", {
@@ -808,10 +798,10 @@ The fallback retry session is now created and can be inspected directly.
includeTeamToolDenylist: input.teamRunId === undefined, includeTeamToolDenylist: input.teamRunId === undefined,
}) })
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>) setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
await promptWithModelSuggestionRetry(this.client, { await promptWithRetryInDirectory(this.client, {
path: { id: sessionID }, path: { id: sessionID },
body: fallbackBody, body: fallbackBody,
}) }, parentDirectory)
task.agent = FALLBACK_AGENT task.agent = FALLBACK_AGENT
return return
} catch (retryError) { } catch (retryError) {
@@ -1156,7 +1146,7 @@ The fallback retry session is now created and can be inspected directly.
applySessionPromptParams(existingTask.sessionId!, existingTask.model) applySessionPromptParams(existingTask.sessionId!, existingTask.model)
} }
this.client.session.promptAsync({ promptAsyncInDirectory(this.client, {
path: { id: existingTask.sessionId }, path: { id: existingTask.sessionId },
body: { body: {
agent: existingTask.agent, agent: existingTask.agent,
@@ -1176,7 +1166,7 @@ The fallback retry session is now created and can be inspected directly.
})(), })(),
parts: [createInternalAgentTextPart(input.prompt)], parts: [createInternalAgentTextPart(input.prompt)],
}, },
}).catch(async (error) => { }, this.directory).catch(async (error) => {
log("[background-agent] resume prompt error:", error) log("[background-agent] resume prompt error:", error)
const errorInfo = { const errorInfo = {
name: extractErrorName(error), name: extractErrorName(error),
@@ -1219,8 +1209,8 @@ The fallback retry session is now created and can be inspected directly.
private async checkSessionTodos(sessionID: string): Promise<boolean> { private async checkSessionTodos(sessionID: string): Promise<boolean> {
const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID) const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID)
if (observedIncompleteTodos !== undefined) { if (observedIncompleteTodos === false) {
return observedIncompleteTodos return false
} }
try { try {
@@ -1260,8 +1250,9 @@ The fallback retry session is now created and can be inspected directly.
this.observedIncompleteTodosBySession.delete(sessionID) this.observedIncompleteTodosBySession.delete(sessionID)
} }
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean { private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
if (!partInfo?.sessionID) return false if (!partInfo) return false
if (!partInfo.sessionID && !sessionID) return false
if (partInfo.tool) return true if (partInfo.tool) return true
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
if (partInfo.type === "text" || partInfo.type === "reasoning") return true if (partInfo.type === "text" || partInfo.type === "reasoning") return true
@@ -1279,9 +1270,9 @@ The fallback retry session is now created and can be inspected directly.
const info = props?.info const info = props?.info
if (!info || typeof info !== "object") return if (!info || typeof info !== "object") return
const sessionID = (info as Record<string, unknown>)["sessionID"] const sessionID = resolveMessageEventSessionID(props)
const role = (info as Record<string, unknown>)["role"] const role = (info as Record<string, unknown>)["role"]
if (typeof sessionID !== "string") return if (!sessionID) return
if (role === "tool") { if (role === "tool") {
this.markSessionOutputObserved(sessionID) this.markSessionOutputObserved(sessionID)
@@ -1312,7 +1303,7 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "message.part.updated" || event.type === "message.part.delta") { if (event.type === "message.part.updated" || event.type === "message.part.delta") {
const partInfo = resolveMessagePartInfo(props) const partInfo = resolveMessagePartInfo(props)
const sessionID = partInfo?.sessionID const sessionID = resolveMessageEventSessionID(props)
if (!sessionID) return if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID) const resolved = this.resolveTaskAttemptBySession(sessionID)
@@ -1320,7 +1311,7 @@ The fallback retry session is now created and can be inspected directly.
const { task } = resolved const { task } = resolved
if (this.hasOutputSignalFromPart(partInfo)) { if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
this.markSessionOutputObserved(sessionID) this.markSessionOutputObserved(sessionID)
} }
@@ -1404,7 +1395,7 @@ The fallback retry session is now created and can be inspected directly.
} }
if (event.type === "todo.updated") { 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 const todos = Array.isArray(props?.todos) ? props.todos : undefined
if (!sessionID || !todos) return if (!sessionID || !todos) return
@@ -1419,12 +1410,6 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "session.idle") { if (event.type === "session.idle") {
if (!props || typeof props !== "object") return if (!props || typeof props !== "object") return
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined
if (sessionID) {
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to flush pending parent wake:", { sessionID, error })
})
}
handleSessionIdleBackgroundEvent({ handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>, properties: props as Record<string, unknown>,
findBySession: (id) => { findBySession: (id) => {
@@ -1440,7 +1425,7 @@ The fallback retry session is now created and can be inspected directly.
} }
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID) const resolved = this.resolveTaskAttemptBySession(sessionID)
@@ -1469,9 +1454,8 @@ The fallback retry session is now created and can be inspected directly.
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const info = props?.info const sessionID = resolveSessionEventID(props)
if (!info || typeof info.id !== "string") return if (!sessionID) return
const sessionID = info.id
this.clearSessionOutputObserved(sessionID) this.clearSessionOutputObserved(sessionID)
this.clearSessionTodoObservation(sessionID) this.clearSessionTodoObservation(sessionID)
@@ -1529,7 +1513,7 @@ The fallback retry session is now created and can be inspected directly.
} }
if (event.type === "session.status") { 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 const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || !status?.type) return if (!sessionID || !status?.type) return
@@ -1758,9 +1742,9 @@ The task was re-queued on a fallback model after a retryable failure.
} }
try { try {
const response = await this.client.session.messages({ const response = await messagesInDirectory(this.client, {
path: { id: sessionID }, path: { id: sessionID },
}) }, this.directory)
const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true }) const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
@@ -2169,7 +2153,9 @@ The task was re-queued on a fallback model after a retryable failure.
if (this.enableParentSessionNotifications) { if (this.enableParentSessionNotifications) {
try { 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<{ const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: { info?: {
agent?: string agent?: string
@@ -2230,42 +2216,30 @@ The task was re-queued on a fallback model after a retryable failure.
...(variant !== undefined ? { variant } : {}), ...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}), ...(resolvedTools ? { tools: resolvedTools } : {}),
} }
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId) try {
await promptAsyncInDirectory(this.client, {
if (shouldDeferReply) { path: { id: task.parentSessionId },
this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext) body: {
log("[background-agent] Deferred notification until parent session is idle:", { noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
}, this.directory)
log("[background-agent] Sent notification to parent session:", {
taskId: task.id, taskId: task.id,
allComplete, allComplete,
isTaskFailure, isTaskFailure,
noReply: !shouldReply,
}) })
} else { } catch (error) {
try { if (isAbortedSessionError(error)) {
await this.client.session.promptAsync({ log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id, taskId: task.id,
allComplete, parentSessionID: task.parentSessionId,
isTaskFailure,
noReply: !shouldReply,
deferredReply: false,
}) })
} catch (error) { this.queuePendingNotification(task.parentSessionId, notification)
if (isAbortedSessionError(error)) { } else {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { log("[background-agent] Failed to send notification:", error)
taskId: task.id,
parentSessionID: task.parentSessionId,
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
} }
} }
} else { } else {
@@ -2287,112 +2261,6 @@ The task was re-queued on a fallback model after a retryable failure.
return false return false
} }
private async isSessionActive(sessionID: string): Promise<boolean> {
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<string, SessionStatusInfo>,
)
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<void> {
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 { private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({ pruneStaleTasksAndNotifications({
tasks: this.tasks, tasks: this.tasks,
@@ -2706,11 +2574,6 @@ The task was re-queued on a fallback model after a retryable failure.
} }
this.idleDeferralTimers.clear() this.idleDeferralTimers.clear()
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const sessionID of trackedSessionIDs) { for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID) subagentSessions.delete(sessionID)
SessionCategoryRegistry.remove(sessionID) SessionCategoryRegistry.remove(sessionID)
@@ -2723,7 +2586,6 @@ The task was re-queued on a fallback model after a retryable failure.
this.pendingNotifications.clear() this.pendingNotifications.clear()
this.pendingByParent.clear() this.pendingByParent.clear()
this.notificationQueueByParent.clear() this.notificationQueueByParent.clear()
this.pendingParentWakes.clear()
this.rootDescendantCounts.clear() this.rootDescendantCounts.clear()
this.queuesByKey.clear() this.queuesByKey.clear()
this.processingKeys.clear() this.processingKeys.clear()
@@ -2,16 +2,17 @@ import { describe, expect, mock, test } from "bun:test"
import type { OpencodeClient } from "./opencode-client" import type { OpencodeClient } from "./opencode-client"
import { verifySessionExists } from "./session-existence" import { verifySessionExists } from "./session-existence"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("verifySessionExists", () => { describe("verifySessionExists", () => {
test("passes query directory to session lookup when provided", async () => { test("passes query directory to session lookup when provided", async () => {
// given // given
const get = mock(async () => ({ data: { id: "session-123" } })) const get = mock(async () => ({ data: { id: "session-123" } }))
const client = { const client = unsafeTestValue<OpencodeClient>({
session: { session: {
get, get,
}, },
} as unknown as OpencodeClient })
// when // when
const result = await verifySessionExists(client, "session-123", "/project/root") const result = await verifySessionExists(client, "session-123", "/project/root")
@@ -1,12 +1,8 @@
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { MIN_IDLE_TIME_MS } from "./constants" import { MIN_IDLE_TIME_MS } from "./constants"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key]
return typeof value === "string" ? value : undefined
}
export function handleSessionIdleBackgroundEvent(args: { export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown> properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined findBySession: (sessionID: string) => BackgroundTask | undefined
@@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: {
emitIdleEvent, emitIdleEvent,
} = args } = args
const sessionID = getString(properties, "sessionID") const sessionID = resolveSessionEventID(properties)
if (!sessionID) return if (!sessionID) return
const task = findBySession(sessionID) const task = findBySession(sessionID)
@@ -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<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async () => ({ data: { id: "ses_child_query" } }),
promptAsync: async (input: Record<string, unknown>) => {
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 () => { test("strips leading zwsp from prompt body agent before promptAsync", async () => {
//#given //#given
const promptCalls: Array<{ body?: { agent?: string } }> = [] const promptCalls: Array<{ body?: { agent?: string } }> = []
+19 -18
View File
@@ -1,6 +1,6 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" 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 { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state" import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager" import { getTaskToastManager } from "../task-toast-manager"
@@ -171,10 +171,10 @@ export async function startTask(
} }
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI. // Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
const promptChain = promptWithModelSuggestionRetry(client, { const promptChain = promptWithRetryInDirectory(client, {
path: { id: sessionID }, path: { id: sessionID },
body: promptBody, body: promptBody,
}).catch(async (error) => { }, parentDirectory).catch(async (error) => {
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", { log("[background-agent] Agent not found, retrying with fallback agent", {
original: input.agent, original: input.agent,
@@ -182,12 +182,12 @@ export async function startTask(
taskId: task.id, taskId: task.id,
}) })
try { try {
await promptWithModelSuggestionRetry(client, { await promptWithRetryInDirectory(client, {
path: { id: sessionID }, path: { id: sessionID },
body: buildFallbackBody(promptBody, FALLBACK_AGENT, { body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined, includeTeamToolDenylist: input.teamRunId === undefined,
}), }),
}) }, parentDirectory)
task.agent = FALLBACK_AGENT task.agent = FALLBACK_AGENT
return return
} catch (retryError) { } catch (retryError) {
@@ -227,18 +227,19 @@ export async function startTask(
export async function resumeTask( export async function resumeTask(
task: BackgroundTask, task: BackgroundTask,
input: ResumeInput, input: ResumeInput,
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError"> ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "directory" | "onTaskError">
): Promise<void> { ): Promise<void> {
const { client, concurrencyManager, onTaskError } = ctx const { client, concurrencyManager, directory, onTaskError } = ctx
if (!task.sessionId) { if (!task.sessionId) {
throw new Error(`Task has no sessionID: ${task.id}`) throw new Error(`Task has no sessionID: ${task.id}`)
} }
const sessionID = task.sessionId
if (task.status === "running") { if (task.status === "running") {
log("[background-agent] Resume skipped - task already running:", { log("[background-agent] Resume skipped - task already running:", {
taskId: task.id, taskId: task.id,
sessionID: task.sessionId, sessionID,
}) })
return return
} }
@@ -262,7 +263,7 @@ export async function resumeTask(
lastUpdate: new Date(), lastUpdate: new Date(),
} }
subagentSessions.add(task.sessionId) subagentSessions.add(sessionID)
const toastManager = getTaskToastManager() const toastManager = getTaskToastManager()
if (toastManager) { 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:", { log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
sessionID: task.sessionId, sessionID,
agent: task.agent, agent: task.agent,
model: task.model, model: task.model,
promptLength: input.prompt.length, promptLength: input.prompt.length,
@@ -291,7 +292,7 @@ export async function resumeTask(
: undefined : undefined
const resumeVariant = task.model?.variant const resumeVariant = task.model?.variant
applySessionPromptParams(task.sessionId, task.model) applySessionPromptParams(sessionID, task.model)
const resumeBody = { const resumeBody = {
agent: task.agent, agent: task.agent,
@@ -308,10 +309,10 @@ export async function resumeTask(
parts: [createInternalAgentTextPart(input.prompt)], parts: [createInternalAgentTextPart(input.prompt)],
} }
client.session.promptAsync({ promptWithRetryInDirectory(client, {
path: { id: task.sessionId }, path: { id: sessionID },
body: resumeBody, body: resumeBody,
}).catch(async (error) => { }, directory).catch(async (error) => {
if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) { if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) {
log("[background-agent] Resume agent not found, retrying with fallback agent", { log("[background-agent] Resume agent not found, retrying with fallback agent", {
original: task.agent, original: task.agent,
@@ -319,12 +320,12 @@ export async function resumeTask(
taskId: task.id, taskId: task.id,
}) })
try { try {
await promptWithModelSuggestionRetry(client, { await promptWithRetryInDirectory(client, {
path: { id: task.sessionId! }, path: { id: sessionID },
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, { body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
includeTeamToolDenylist: task.teamRunId === undefined, includeTeamToolDenylist: task.teamRunId === undefined,
}), }),
}) }, directory)
task.agent = FALLBACK_AGENT task.agent = FALLBACK_AGENT
return return
} catch (retryError) { } catch (retryError) {
@@ -6,6 +6,7 @@ import {
DEFAULT_MAX_SUBAGENT_DEPTH, DEFAULT_MAX_SUBAGENT_DEPTH,
createSubagentDepthLimitError, createSubagentDepthLimitError,
} from "./subagent-spawn-limits" } from "./subagent-spawn-limits"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient { function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
return { return {
@@ -20,14 +21,14 @@ describe("resolveSubagentSpawnContext", () => {
test("passes query.directory to each session.get call", async () => { test("passes query.directory to each session.get call", async () => {
// given // given
const sessionGetCalls: Array<Record<string, unknown>> = [] const sessionGetCalls: Array<Record<string, unknown>> = []
const client = createMockClient((async (input) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (input) => {
sessionGetCalls.push(input as Record<string, unknown>) sessionGetCalls.push(input as Record<string, unknown>)
if (input.path.id === "child-session") { if (input.path.id === "child-session") {
return { data: { id: "child-session", parentID: "root-session" } } return { data: { id: "child-session", parentID: "root-session" } }
} }
return { data: { id: "root-session", parentID: undefined } } return { data: { id: "root-session", parentID: undefined } }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when // when
const result = await resolveSubagentSpawnContext(client, "child-session", "/project/root") const result = await resolveSubagentSpawnContext(client, "child-session", "/project/root")
@@ -50,10 +51,10 @@ describe("resolveSubagentSpawnContext", () => {
describe("#given session.get returns an SDK error response", () => { describe("#given session.get returns an SDK error response", () => {
test("throws a fail-closed spawn blocked error", async () => { test("throws a fail-closed spawn blocked error", async () => {
// given // given
const client = createMockClient((async () => ({ const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
error: "lookup failed", error: "lookup failed",
data: undefined, data: undefined,
})) as unknown as OpencodeClient["session"]["get"]) }))))
// when // when
const result = resolveSubagentSpawnContext(client, "parent-session") const result = resolveSubagentSpawnContext(client, "parent-session")
@@ -66,9 +67,9 @@ describe("resolveSubagentSpawnContext", () => {
describe("#given session.get returns no session data", () => { describe("#given session.get returns no session data", () => {
test("throws a fail-closed spawn blocked error", async () => { test("throws a fail-closed spawn blocked error", async () => {
// given // given
const client = createMockClient((async () => ({ const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
data: undefined, data: undefined,
})) as unknown as OpencodeClient["session"]["get"]) }))))
// when // when
const result = resolveSubagentSpawnContext(client, "parent-session") const result = resolveSubagentSpawnContext(client, "parent-session")
@@ -81,12 +82,12 @@ describe("resolveSubagentSpawnContext", () => {
describe("depth calculation smoke tests (regression guard)", () => { describe("depth calculation smoke tests (regression guard)", () => {
test("root session (no parentID) reports depth 0 and childDepth 1", async () => { test("root session (no parentID) reports depth 0 and childDepth 1", async () => {
// given - a root session with no parent // given - a root session with no parent
const client = createMockClient((async (opts) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
if (opts.path.id === "root-session") { if (opts.path.id === "root-session") {
return { data: { id: "root-session", parentID: undefined } } return { data: { id: "root-session", parentID: undefined } }
} }
return { error: "not found", data: undefined } return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when // when
const result = await resolveSubagentSpawnContext(client, "root-session") const result = await resolveSubagentSpawnContext(client, "root-session")
@@ -99,7 +100,7 @@ describe("resolveSubagentSpawnContext", () => {
test("depth-1 child reports childDepth 2", async () => { test("depth-1 child reports childDepth 2", async () => {
// given - child -> root chain // given - child -> root chain
const client = createMockClient((async (opts) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
if (opts.path.id === "child-1") { if (opts.path.id === "child-1") {
return { data: { id: "child-1", parentID: "root-session" } } return { data: { id: "child-1", parentID: "root-session" } }
} }
@@ -107,7 +108,7 @@ describe("resolveSubagentSpawnContext", () => {
return { data: { id: "root-session", parentID: undefined } } return { data: { id: "root-session", parentID: undefined } }
} }
return { error: "not found", data: undefined } return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when // when
const result = await resolveSubagentSpawnContext(client, "child-1") const result = await resolveSubagentSpawnContext(client, "child-1")
@@ -120,7 +121,7 @@ describe("resolveSubagentSpawnContext", () => {
test("depth-2 grandchild reports childDepth 3", async () => { test("depth-2 grandchild reports childDepth 3", async () => {
// given - grandchild -> child -> root chain // given - grandchild -> child -> root chain
const client = createMockClient((async (opts) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const sessions: Record<string, { id: string; parentID?: string }> = { const sessions: Record<string, { id: string; parentID?: string }> = {
"grandchild": { id: "grandchild", parentID: "child" }, "grandchild": { id: "grandchild", parentID: "child" },
"child": { id: "child", parentID: "root" }, "child": { id: "child", parentID: "root" },
@@ -129,7 +130,7 @@ describe("resolveSubagentSpawnContext", () => {
const session = sessions[opts.path.id] const session = sessions[opts.path.id]
if (session) return { data: session } if (session) return { data: session }
return { error: "not found", data: undefined } return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when // when
const result = await resolveSubagentSpawnContext(client, "grandchild") const result = await resolveSubagentSpawnContext(client, "grandchild")
@@ -153,11 +154,11 @@ describe("resolveSubagentSpawnContext", () => {
} }
} }
const client = createMockClient((async (opts) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const session = sessions[opts.path.id] const session = sessions[opts.path.id]
if (session) return { data: session } if (session) return { data: session }
return { error: "not found", data: undefined } return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when - resolve from the deepest session // when - resolve from the deepest session
const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}` const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}`
@@ -170,7 +171,7 @@ describe("resolveSubagentSpawnContext", () => {
test("detects parent cycle and throws", async () => { test("detects parent cycle and throws", async () => {
// given - A -> B -> A (cycle) // given - A -> B -> A (cycle)
const client = createMockClient((async (opts) => { const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const sessions: Record<string, { id: string; parentID?: string }> = { const sessions: Record<string, { id: string; parentID?: string }> = {
"session-a": { id: "session-a", parentID: "session-b" }, "session-a": { id: "session-a", parentID: "session-b" },
"session-b": { id: "session-b", parentID: "session-a" }, "session-b": { id: "session-b", parentID: "session-a" },
@@ -178,7 +179,7 @@ describe("resolveSubagentSpawnContext", () => {
const session = sessions[opts.path.id] const session = sessions[opts.path.id]
if (session) return { data: session } if (session) return { data: session }
return { error: "not found", data: undefined } return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"]) })))
// when // when
const result = resolveSubagentSpawnContext(client, "session-a") const result = resolveSubagentSpawnContext(client, "session-a")
@@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { TASK_CLEANUP_DELAY_MS } from "./constants" import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { BackgroundManager } from "./manager" import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
type PromptAsyncCall = { type PromptAsyncCall = {
path: { id: string } path: { id: string }
@@ -11,6 +12,9 @@ type PromptAsyncCall = {
noReply?: boolean noReply?: boolean
parts?: unknown[] parts?: unknown[]
} }
query?: {
directory: string
}
} }
type FakeTimers = { type FakeTimers = {
@@ -159,14 +163,6 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
return notifyParentSession.call(manager, task) return notifyParentSession.call(manager, task)
} }
function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> { function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID) const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined() expect(timer).toBeDefined()
@@ -241,6 +237,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// then // then
expect(promptAsyncCalls).toHaveLength(2) expect(promptAsyncCalls).toHaveLength(2)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(getCompletionTimers(manager).size).toBe(2) expect(getCompletionTimers(manager).size).toBe(2)
const allCompleteCall = promptAsyncCalls[1] const allCompleteCall = promptAsyncCalls[1]
expect(allCompleteCall).toBeDefined() expect(allCompleteCall).toBeDefined()
@@ -251,13 +248,14 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
expect(allCompleteCall.body.noReply).toBe(false) expect(allCompleteCall.body.noReply).toBe(false)
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts) const allCompletePayload = JSON.stringify(allCompleteCall.body.parts)
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE") expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
expect(allCompletePayload).toContain(taskA.id) expect(allCompletePayload).toContain(taskA.id)
expect(allCompletePayload).toContain(taskB.id) expect(allCompletePayload).toContain(taskB.id)
expect(allCompletePayload).toContain(taskA.description) expect(allCompletePayload).toContain(taskA.description)
expect(allCompletePayload).toContain(taskB.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 // given
const sessionStatuses: Record<string, { type: string }> = { const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" }, "parent-1": { type: "busy" },
@@ -272,10 +270,32 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await notifyParentSessionForTest(manager, task) await notifyParentSessionForTest(manager, task)
// then // then
expect(promptAsyncCalls).toHaveLength(0) expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
}) })
test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => { test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
managerUnderTest = manager
const directory = Reflect.get(manager, "directory") as string
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
expect(promptAsyncCalls[0]?.query).toEqual({ directory })
})
test("#when busy parent later becomes idle #then completion notification is not replayed as a second parent prompt", async () => {
// given // given
const sessionStatuses: Record<string, { type: string }> = { const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" }, "parent-1": { type: "busy" },
@@ -286,21 +306,22 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
getTasks(manager).set(task.id, task) getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task) await notifyParentSessionForTest(manager, task)
expect(promptAsyncCalls).toHaveLength(1)
// when // when
sessionStatuses["parent-1"] = { type: "idle" } sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake() await Promise.resolve()
// then // then
expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false) expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") 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 // given
const sessionStatuses: Record<string, { type: string }> = { const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" }, "parent-1": { type: "busy" },
@@ -314,22 +335,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// when // when
await notifyParentSessionForTest(manager, task) await notifyParentSessionForTest(manager, task)
sessionStatuses["parent-1"] = { type: "idle" } sessionStatuses["parent-1"] = { type: "idle" }
await waitForDeferredWakeRetry() await new Promise((resolve) => setTimeout(resolve, 1_180))
// then // then
expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false) expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") 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 // given
const sessionStatuses: Record<string, { type: string }> = { const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" }, "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 () => { const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => {
throw promptError throw promptError
}) })
@@ -337,12 +359,9 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) 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) getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
// when // when
sessionStatuses["parent-1"] = { type: "idle" } await notifyParentSessionForTest(manager, task)
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
// then // then
expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls).toHaveLength(1)
@@ -11,6 +11,7 @@ import type {
import { transformMcpServer } from "./transformer" import { transformMcpServer } from "./transformer"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { shouldLoadMcpServer } from "./scope-filter" import { shouldLoadMcpServer } from "./scope-filter"
import { bunFile } from "../../shared/bun-file-shim"
interface McpConfigPath { interface McpConfigPath {
path: string path: string
@@ -37,7 +38,7 @@ async function loadMcpConfigFile(
} }
try { try {
const content = await Bun.file(filePath).text() const content = await bunFile(filePath).text()
return JSON.parse(content) as ClaudeCodeMcpConfig return JSON.parse(content) as ClaudeCodeMcpConfig
} catch (error) { } catch (error) {
log(`Failed to load MCP config from ${filePath}`, error) log(`Failed to load MCP config from ${filePath}`, error)
@@ -7,6 +7,7 @@ import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import type { LoadedPlugin } from "./types" import type { LoadedPlugin } from "./types"
import { resolvePluginPaths } from "./plugin-path-resolver" import { resolvePluginPaths } from "./plugin-path-resolver"
import { bunFile } from "../../shared/bun-file-shim"
export async function loadPluginMcpServers( export async function loadPluginMcpServers(
plugins: LoadedPlugin[], plugins: LoadedPlugin[],
@@ -18,7 +19,7 @@ export async function loadPluginMcpServers(
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
try { try {
const content = await Bun.file(plugin.mcpPath).text() const content = await bunFile(plugin.mcpPath).text()
let config = JSON.parse(content) as ClaudeCodeMcpConfig let config = JSON.parse(content) as ClaudeCodeMcpConfig
config = resolvePluginPaths(config, plugin.installPath) config = resolvePluginPaths(config, plugin.installPath)
@@ -3,6 +3,7 @@ import { ContextCollector } from "./collector"
import { import {
createContextInjectorMessagesTransformHook, createContextInjectorMessagesTransformHook,
} from "./injector" } from "./injector"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("createContextInjectorMessagesTransformHook", () => { describe("createContextInjectorMessagesTransformHook", () => {
let collector: ContextCollector let collector: ContextCollector
@@ -51,7 +52,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
createMockMessage("user", "Second message", sessionID), createMockMessage("user", "Second message", sessionID),
] ]
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any const output = unsafeTestValue({ messages })
// when // when
await hook["experimental.chat.messages.transform"]!({}, output) await hook["experimental.chat.messages.transform"]!({}, output)
@@ -115,7 +116,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
const sessionID = "ses_transform2" const sessionID = "ses_transform2"
const messages = [createMockMessage("user", "Hello world", sessionID)] const messages = [createMockMessage("user", "Hello world", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any const output = unsafeTestValue({ messages })
// when // when
await hook["experimental.chat.messages.transform"]!({}, output) await hook["experimental.chat.messages.transform"]!({}, output)
@@ -135,7 +136,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
}) })
const messages = [createMockMessage("assistant", "Response", sessionID)] const messages = [createMockMessage("assistant", "Response", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any const output = unsafeTestValue({ messages })
// when // when
await hook["experimental.chat.messages.transform"]!({}, output) await hook["experimental.chat.messages.transform"]!({}, output)
@@ -156,7 +157,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
}) })
const messages = [createMockMessage("user", "Message", sessionID)] const messages = [createMockMessage("user", "Message", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any const output = unsafeTestValue({ messages })
// when // when
await hook["experimental.chat.messages.transform"]!({}, output) await hook["experimental.chat.messages.transform"]!({}, output)
+11 -4
View File
@@ -79,6 +79,14 @@ type MessagesTransformHook = {
) => Promise<void> ) => Promise<void>
} }
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( export function createContextInjectorMessagesTransformHook(
collector: ContextCollector collector: ContextCollector
): MessagesTransformHook { ): MessagesTransformHook {
@@ -106,8 +114,7 @@ export function createContextInjectorMessagesTransformHook(
} }
const lastUserMessage = messages[lastUserMessageIndex] const lastUserMessage = messages[lastUserMessageIndex]
// Try message.info.sessionID first, fallback to mainSessionID const messageSessionID = getSessionIDFromMessageInfo(lastUserMessage.info)
const messageSessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
const sessionID = messageSessionID ?? getMainSessionID() const sessionID = messageSessionID ?? getMainSessionID()
log("[DEBUG] Extracted sessionID", { log("[DEBUG] Extracted sessionID", {
messageSessionID, messageSessionID,
@@ -135,7 +142,7 @@ export function createContextInjectorMessagesTransformHook(
} }
const textPartIndex = lastUserMessage.parts.findIndex( const textPartIndex = lastUserMessage.parts.findIndex(
(p) => p.type === "text" && (p as { text?: string }).text (p) => p.type === "text" && hasText(p)
) )
if (textPartIndex === -1) { if (textPartIndex === -1) {
@@ -150,7 +157,7 @@ export function createContextInjectorMessagesTransformHook(
const syntheticPart = { const syntheticPart = {
id: `synthetic_hook_${sessionID}`, id: `synthetic_hook_${sessionID}`,
messageID: lastUserMessage.info.id, messageID: lastUserMessage.info.id,
sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "", sessionID: messageSessionID ?? "",
type: "text" as const, type: "text" as const,
text: pending.merged, text: pending.merged,
synthetic: true, // hidden in UI synthetic: true, // hidden in UI
@@ -11,6 +11,7 @@ import {
injectHookMessage, injectHookMessage,
} from "./injector" } from "./injector"
import { getCompactionPartStorageDir } from "../../shared/compaction-marker" import { getCompactionPartStorageDir } from "../../shared/compaction-marker"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
//#region Mocks //#region Mocks
@@ -73,7 +74,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
{ info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } }, { info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toEqual({ expect(result).toEqual({
agent: "sisyphus", agent: "sisyphus",
@@ -87,7 +88,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
{ info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } }, { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toEqual({ expect(result).toEqual({
agent: "sisyphus", agent: "sisyphus",
@@ -102,7 +103,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
{ id: "msg_new", info: { agent: "new-agent", model: { providerID: "new", modelID: "model" }, time: { created: 20 } } }, { id: "msg_new", info: { agent: "new-agent", model: { providerID: "new", modelID: "model" }, time: { created: 20 } } },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result?.agent).toBe("new-agent") expect(result?.agent).toBe("new-agent")
}) })
@@ -112,7 +113,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
{ info: { agent: "partial-agent" } }, { info: { agent: "partial-agent" } },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result?.agent).toBe("partial-agent") expect(result?.agent).toBe("partial-agent")
}) })
@@ -123,7 +124,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
{ info: {} }, { info: {} },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBeNull() expect(result).toBeNull()
}) })
@@ -131,7 +132,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
it("returns null when messages array is empty", async () => { it("returns null when messages array is empty", async () => {
const mockClient = createMockClient([]) const mockClient = createMockClient([])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBeNull() expect(result).toBeNull()
}) })
@@ -145,7 +146,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
}, },
} }
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBeNull() expect(result).toBeNull()
}) })
@@ -161,7 +162,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
}, },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result?.tools).toEqual({ edit: true, write: false }) 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 } } }, { id: "msg_older", info: { agent: "newest-by-time", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 100 } } },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result?.agent).toBe("newest-by-time") expect(result?.agent).toBe("newest-by-time")
}) })
@@ -190,7 +191,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
}, },
]) ])
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result?.agent).toBe("sisyphus") expect(result?.agent).toBe("sisyphus")
}) })
@@ -252,7 +253,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
{ info: { agent: "second-agent" } }, { info: { agent: "second-agent" } },
]) ])
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBe("first-agent") expect(result).toBe("first-agent")
}) })
@@ -263,7 +264,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
{ id: "msg_early", info: { agent: "earliest-agent", time: { created: 10 } } }, { id: "msg_early", info: { agent: "earliest-agent", time: { created: 10 } } },
]) ])
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBe("earliest-agent") expect(result).toBe("earliest-agent")
}) })
@@ -274,7 +275,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } }, { id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } },
]) ])
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBe("sisyphus") expect(result).toBe("sisyphus")
}) })
@@ -285,7 +286,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
{ info: { agent: "first-real-agent" } }, { info: { agent: "first-real-agent" } },
]) ])
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBe("first-real-agent") expect(result).toBe("first-real-agent")
}) })
@@ -296,7 +297,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
{ info: {} }, { info: {} },
]) ])
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBeNull() expect(result).toBeNull()
}) })
@@ -310,7 +311,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
}, },
} }
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
expect(result).toBeNull() expect(result).toBeNull()
}) })
+55 -36
View File
@@ -1,3 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils" import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils"
const DEFAULT_PORT = 19877 const DEFAULT_PORT = 19877
@@ -51,56 +53,73 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes")) rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
server.stop(true) server.close()
}, TIMEOUT_MS) }, TIMEOUT_MS)
const server = Bun.serve({ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
port: requestedPort, const url = new URL(request.url ?? "/", "http://127.0.0.1")
hostname: "127.0.0.1",
fetch(request: Request): Response {
const url = new URL(request.url)
if (url.pathname !== "/oauth/callback") { if (url.pathname !== "/oauth/callback") {
return new Response("Not Found", { status: 404 }) response.statusCode = 404
} response.end("Not Found")
return
}
const oauthError = url.searchParams.get("error") const oauthError = url.searchParams.get("error")
if (oauthError) { if (oauthError) {
const description = url.searchParams.get("error_description") ?? 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 })
clearTimeout(timeoutId) 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, { if (!code || !state) {
headers: { "content-type": "text/html; charset=utf-8" }, 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<void>((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 { return {
port: activePort, port: activePort,
waitForCallback: () => callbackPromise, waitForCallback: () => callbackPromise,
close: () => { close: () => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
server.stop(true) server.close()
}, },
} }
} }
@@ -6,6 +6,7 @@ import type { OAuthTokenData } from "../mcp-oauth/storage"
import { setHttpClientDependenciesForTesting } from "./http-client" import { setHttpClientDependenciesForTesting } from "./http-client"
import { setStdioClientDependenciesForTesting } from "./stdio-client" import { setStdioClientDependenciesForTesting } from "./stdio-client"
import { SkillMcpManager } from "./manager" import { SkillMcpManager } from "./manager"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve()) const mockHttpClose = mock(() => Promise.resolve())
@@ -634,7 +635,7 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()),
} }
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient) getOrCreateSpy.mockResolvedValue(mockClient)
// when // when
@@ -668,7 +669,7 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()),
} }
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient) getOrCreateSpy.mockResolvedValue(mockClient)
// when / #then // when / #then
@@ -700,7 +701,7 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()),
} }
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient) getOrCreateSpy.mockResolvedValue(mockClient)
// when / #then // when / #then
@@ -929,7 +930,7 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()),
} }
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient) getOrCreateSpy.mockResolvedValue(mockClient)
// when // when
@@ -962,7 +963,7 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()),
} }
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry") const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient) getOrCreateSpy.mockResolvedValue(mockClient)
// when / #then // when / #then
@@ -1,6 +1,7 @@
declare const require: (name: string) => any declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test") const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
import type { ConcurrencyManager } from "../background-agent/concurrency" import type { ConcurrencyManager } from "../background-agent/concurrency"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type TaskToastManagerClass = typeof import("./manager").TaskToastManager type TaskToastManagerClass = typeof import("./manager").TaskToastManager
@@ -20,15 +21,15 @@ describe("TaskToastManager", () => {
showToast: mock(() => Promise.resolve()), showToast: mock(() => Promise.resolve()),
}, },
} }
mockConcurrencyManager = { mockConcurrencyManager = unsafeTestValue<ConcurrencyManager>({
getConcurrencyLimit: mock(() => 5), getConcurrencyLimit: mock(() => 5),
} as unknown as ConcurrencyManager })
const mod = await import("./manager") const mod = await import("./manager")
TaskToastManager = mod.TaskToastManager TaskToastManager = mod.TaskToastManager
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
toastManager = new TaskToastManager(mockClient as any, mockConcurrencyManager) toastManager = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyManager)
}) })
afterEach(() => { afterEach(() => {
@@ -108,14 +109,14 @@ describe("TaskToastManager", () => {
test("should display concurrency limit info when available", () => { test("should display concurrency limit info when available", () => {
// given - a concurrency manager with known limit // given - a concurrency manager with known limit
const mockConcurrencyWithCounts = { const mockConcurrencyWithCounts = unsafeTestValue<ConcurrencyManager>({
getConcurrencyLimit: mock(() => 5), getConcurrencyLimit: mock(() => 5),
getRunningCount: mock(() => 2), getRunningCount: mock(() => 2),
getQueuedCount: mock(() => 1), getQueuedCount: mock(() => 1),
} as unknown as ConcurrencyManager })
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const managerWithConcurrency = new TaskToastManager(mockClient as any, mockConcurrencyWithCounts) const managerWithConcurrency = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyWithCounts)
// when - a task is added // when - a task is added
managerWithConcurrency.addTask({ managerWithConcurrency.addTask({
@@ -357,11 +358,11 @@ describe("TaskToastManager", () => {
test("should show model name in queued tasks too", () => { test("should show model name in queued tasks too", () => {
// given - a concurrency manager that limits to 1 // given - a concurrency manager that limits to 1
const limitedConcurrency = { const limitedConcurrency = unsafeTestValue<ConcurrencyManager>({
getConcurrencyLimit: mock(() => 1), getConcurrencyLimit: mock(() => 1),
} as unknown as ConcurrencyManager })
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency) const limitedManager = new TaskToastManager(unsafeTestValue(mockClient), limitedConcurrency)
limitedManager.addTask({ limitedManager.addTask({
id: "task_running", id: "task_running",
+2 -1
View File
@@ -1,4 +1,5 @@
import type { TeamModeConfig } from "../../config/schema/team-mode" import type { TeamModeConfig } from "../../config/schema/team-mode"
import { spawn } from "../../shared/bun-spawn-shim"
export interface TeamModeDependencyReport { export interface TeamModeDependencyReport {
tmuxAvailable: boolean tmuxAvailable: boolean
@@ -20,7 +21,7 @@ export async function checkTeamModeDependencies(
async function probeBinary(cmd: string, args: string[]): Promise<boolean> { async function probeBinary(cmd: string, args: string[]): Promise<boolean> {
try { 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 const code = await proc.exited
return code === 0 return code === 0
} catch { } catch {
@@ -16,6 +16,7 @@ import {
import { saveRuntimeState } from "../team-state-store/store" import { saveRuntimeState } from "../team-state-store/store"
import type { RuntimeState } from "../types" import type { RuntimeState } from "../types"
import { cleanupTeamRunResources } from "./cleanup-team-run-resources" import { cleanupTeamRunResources } from "./cleanup-team-run-resources"
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
const temporaryDirectories: string[] = [] const temporaryDirectories: string[] = []
@@ -41,9 +42,9 @@ function createRuntimeState(teamRunId: string): RuntimeState {
} }
function createStubBgMgr(): BackgroundManager { function createStubBgMgr(): BackgroundManager {
return { return unsafeTestValue<BackgroundManager>({
cancelTask: async () => undefined, cancelTask: async () => undefined,
} as unknown as BackgroundManager })
} }
describe("cleanupTeamRunResources", () => { describe("cleanupTeamRunResources", () => {
+3 -2
View File
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig, WindowState } from "./types" import type { TrackedSession, CapacityConfig, WindowState } from "./types"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { import {
isInsideTmux as defaultIsInsideTmux, isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId, getCurrentPaneId as defaultGetCurrentPaneId,
@@ -1098,9 +1099,9 @@ export class TmuxSessionManager {
if (event.type !== "session.created") return if (event.type !== "session.created") return
const info = event.properties?.info 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" const title = info.title ?? "Subagent"
if (!this.sourcePaneId) { if (!this.sourcePaneId) {
@@ -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<string, TrackedSession>()
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)
})
})
@@ -1,6 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { TmuxPollingManager } from "./polling-manager" import { TmuxPollingManager } from "./polling-manager"
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("TmuxPollingManager overlap", () => { describe("TmuxPollingManager overlap", () => {
test("skips overlapping pollSessions executions", async () => { test("skips overlapping pollSessions executions", async () => {
@@ -39,15 +40,15 @@ describe("TmuxPollingManager overlap", () => {
} }
const manager = new TmuxPollingManager( const manager = new TmuxPollingManager(
client as unknown as import("../../tools/delegate-task/types").OpencodeClient, unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
sessions, sessions,
async () => {}, async () => {},
) )
//#when //#when
const firstPoll = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions() const firstPoll = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions()
await Promise.resolve() await Promise.resolve()
const secondPoll = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions() const secondPoll = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions()
releaseStatus?.() releaseStatus?.()
await Promise.all([firstPoll, secondPoll]) await Promise.all([firstPoll, secondPoll])
@@ -85,7 +86,7 @@ describe("TmuxPollingManager overlap", () => {
} }
const manager = new TmuxPollingManager( const manager = new TmuxPollingManager(
client as unknown as import("../../tools/delegate-task/types").OpencodeClient, unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
sessions, sessions,
async (sessionId) => { async (sessionId) => {
closedSessionIds.push(sessionId) closedSessionIds.push(sessionId)
@@ -98,7 +99,7 @@ describe("TmuxPollingManager overlap", () => {
}) })
//#when //#when
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
await pollSessions.call(manager) await pollSessions.call(manager)
await pollSessions.call(manager) await pollSessions.call(manager)
await pollSessions.call(manager) await pollSessions.call(manager)
@@ -132,7 +133,7 @@ describe("TmuxPollingManager overlap", () => {
} }
const manager = new TmuxPollingManager( const manager = new TmuxPollingManager(
client as unknown as import("../../tools/delegate-task/types").OpencodeClient, unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
sessions, sessions,
async (sessionId) => { async (sessionId) => {
closedSessionIds.push(sessionId) closedSessionIds.push(sessionId)
@@ -140,7 +141,7 @@ describe("TmuxPollingManager overlap", () => {
) )
// when // when
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
await pollSessions.call(manager) await pollSessions.call(manager)
// then // then
@@ -171,7 +172,7 @@ describe("TmuxPollingManager overlap", () => {
} }
const manager = new TmuxPollingManager( const manager = new TmuxPollingManager(
client as unknown as import("../../tools/delegate-task/types").OpencodeClient, unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
sessions, sessions,
async (sessionId) => { async (sessionId) => {
closedSessionIds.push(sessionId) closedSessionIds.push(sessionId)
@@ -179,7 +180,7 @@ describe("TmuxPollingManager overlap", () => {
) )
// when // when
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
await pollSessions.call(manager) await pollSessions.call(manager)
// then // then
@@ -222,13 +223,13 @@ describe("TmuxPollingManager overlap", () => {
} }
manager = new TmuxPollingManager( manager = new TmuxPollingManager(
client as unknown as import("../../tools/delegate-task/types").OpencodeClient, unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
sessions, sessions,
async (sessionId) => { async (sessionId) => {
closedSessionIds.push(sessionId) closedSessionIds.push(sessionId)
}, },
) )
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
// when // when
await pollSessions.call(manager) await pollSessions.call(manager)
@@ -7,6 +7,7 @@ import {
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
const MIN_STABILITY_TIME_MS = 10 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000
const STABLE_POLLS_REQUIRED = 3 const STABLE_POLLS_REQUIRED = 3
@@ -170,10 +171,7 @@ export class TmuxPollingManager {
if (!properties) return undefined if (!properties) return undefined
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = properties.info return resolveMessageEventSessionID(properties)
if (!info || typeof info !== "object") return undefined
const sessionId = (info as { sessionID?: unknown }).sessionID
return typeof sessionId === "string" ? sessionId : undefined
} }
if ( if (
@@ -182,8 +180,7 @@ export class TmuxPollingManager {
|| event.type === "message.part.removed" || event.type === "message.part.removed"
|| event.type === "message.removed" || event.type === "message.removed"
) { ) {
const sessionId = properties.sessionID return resolveMessageEventSessionID(properties)
return typeof sessionId === "string" ? sessionId : undefined
} }
return undefined return undefined
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { CapacityConfig, TrackedSession } from "./types" import type { CapacityConfig, TrackedSession } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { queryWindowState } from "./pane-state-querier" import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine" import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor" import { executeActions } from "./action-executor"
@@ -44,9 +45,9 @@ export async function handleSessionCreated(
if (event.type !== "session.created") return if (event.type !== "session.created") return
const info = event.properties?.info 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" const title = info.title ?? "Subagent"
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
+5 -5
View File
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types"; import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state"; import { getSessionAgent } from "../../features/claude-code-session-state";
import { getAgentConfigKey } from "../../shared/agent-display-names"; import { getAgentConfigKey } from "../../shared/agent-display-names";
import { resolveSessionEventID } from "../../shared/event-session-id";
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string; tool: string;
@@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
resetState(sessionInfo.id); resetState(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
resetState(sessionID); resetState(sessionID);
} }
@@ -5,6 +5,7 @@ import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types" import type { AutoCompactState } from "./types"
import * as recoveryStrategy from "./recovery-strategy" import * as recoveryStrategy from "./recovery-strategy"
import * as messagesReader from "../session-recovery/storage/messages-reader" import * as messagesReader from "../session-recovery/storage/messages-reader"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type TimerCallback = (...args: any[]) => void type TimerCallback = (...args: any[]) => void
@@ -37,7 +38,7 @@ function createFakeTimeouts(): FakeTimeouts {
callback, callback,
args, args,
}) })
return id as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
}) as typeof setTimeout }) as typeof setTimeout
globalThis.clearTimeout = ((id?: number) => { globalThis.clearTimeout = ((id?: number) => {
@@ -243,7 +244,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig) await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
// then: Toast should be shown // then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
const blockedToast = toastCalls.find( const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress", (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) await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
// then: Should show failure toast // then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
const failureToast = toastCalls.find( const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed", (call: any) => call[0]?.body?.title === "Auto Compact Failed",
) )
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config" import type { ExperimentalConfig } from "../../config"
import * as originalDeduplicationRecovery from "./deduplication-recovery" import * as originalDeduplicationRecovery from "./deduplication-recovery"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const attemptDeduplicationRecoveryMock = mock(async () => {}) const attemptDeduplicationRecoveryMock = mock(async () => {})
@@ -20,7 +21,7 @@ function createImmediateTimeouts(): () => void {
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args) callback(...args)
return 0 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout }) as typeof setTimeout
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { attemptDeduplicationRecovery } from "./deduplication-recovery"
import { clearSessionState } from "./state" import { clearSessionState } from "./state"
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map" import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions { export interface AnthropicContextWindowLimitRecoveryOptions {
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id) clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
clearSessionState(autoCompactState, sessionInfo.id) clearSessionState(autoCompactState, sessionID)
} }
return return
} }
if (event.type === "session.error") { 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 }) dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return if (!sessionID) return
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(props)
if (sessionID && info?.role === "assistant" && info.error) { if (sessionID && info?.role === "assistant" && info.error) {
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: 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") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return if (!autoCompactState.pendingCompact.has(sessionID)) return
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy" import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types" import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config" import type { OhMyOpenCodeConfig } from "../../config"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type TimeoutCall = { type TimeoutCall = {
handle: ReturnType<typeof setTimeout> handle: ReturnType<typeof setTimeout>
@@ -95,7 +96,7 @@ describe("runSummarizeRetryStrategy", () => {
//#given //#given
const timeoutCalls: TimeoutCall[] = [] const timeoutCalls: TimeoutCall[] = []
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => { globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
const handle = timeoutCalls.length + 1 as unknown as ReturnType<typeof setTimeout> const handle = unsafeTestValue<ReturnType<typeof setTimeout>>(timeoutCalls.length + 1)
timeoutCalls.push({ handle, delay: delay ?? 0 }) timeoutCalls.push({ handle, delay: delay ?? 0 })
return handle return handle
}) as typeof setTimeout }) as typeof setTimeout
@@ -132,7 +133,7 @@ describe("runSummarizeRetryStrategy", () => {
let scheduledCallback: (() => void) | undefined let scheduledCallback: (() => void) | undefined
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => {
scheduledCallback = () => callback() scheduledCallback = () => callback()
return 1 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(1)
}) as typeof setTimeout }) as typeof setTimeout
autoCompactState.pendingCompact.add(sessionID) autoCompactState.pendingCompact.add(sessionID)
@@ -176,7 +177,7 @@ describe("runSummarizeRetryStrategy", () => {
autoCompactState.emptyContentAttemptBySession.set(sessionID, 3) autoCompactState.emptyContentAttemptBySession.set(sessionID, 3)
autoCompactState.retryTimerBySession.set( autoCompactState.retryTimerBySession.set(
sessionID, sessionID,
1 as unknown as ReturnType<typeof setTimeout>, unsafeTestValue<ReturnType<typeof setTimeout>>(1),
) )
//#when //#when
+52 -51
View File
@@ -7,6 +7,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createAtlasHook } from "./atlas-hook" import { createAtlasHook } from "./atlas-hook"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-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) // Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests)
mock.module("../../shared/opencode-storage-detection", () => ({ mock.module("../../shared/opencode-storage-detection", () => ({
@@ -79,7 +80,7 @@ describe("atlas background task retry", () => {
callback: () => (callback as LongTimerCallback)(...args), callback: () => (callback as LongTimerCallback)(...args),
cleared: false, cleared: false,
}) })
return id as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
} }
return originalSetTimeout(callback, delay, ...args) return originalSetTimeout(callback, delay, ...args)
@@ -120,7 +121,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true let backgroundRunning = true
const promptMock = mock(async () => ({})) const promptMock = mock(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -128,13 +129,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }> getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}, }>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
}) })
// when // when
@@ -161,7 +162,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true let backgroundRunning = true
const promptMock = mock(async () => ({})) const promptMock = mock(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -169,13 +170,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }> getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}, }>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
}) })
// when // when
@@ -204,7 +205,7 @@ describe("atlas background task retry", () => {
let remainingRunningRetries = 2 let remainingRunningRetries = 2
const promptMock = mock(async () => ({})) const promptMock = mock(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -212,9 +213,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => { getTasksByParentSession: () => {
if (remainingRunningRetries > 0) { if (remainingRunningRetries > 0) {
remainingRunningRetries -= 1 remainingRunningRetries -= 1
@@ -223,9 +226,7 @@ describe("atlas background task retry", () => {
return [] return []
}, },
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & { }),
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}) })
// when // when
@@ -258,7 +259,7 @@ describe("atlas background task retry", () => {
const promptAsyncMock = mock(async () => ({})) const promptAsyncMock = mock(async () => ({}))
let backgroundCheckCount = 0 let backgroundCheckCount = 0
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -266,9 +267,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => { getTasksByParentSession: () => {
backgroundCheckCount += 1 backgroundCheckCount += 1
if (backgroundCheckCount === 1) { if (backgroundCheckCount === 1) {
@@ -281,9 +284,7 @@ describe("atlas background task retry", () => {
return [] return []
}, },
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & { }),
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}) })
// when // when
@@ -313,7 +314,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true let backgroundRunning = true
const promptAsyncMock = mock(async () => ({})) const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -321,13 +322,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }> getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}, }>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
}) })
// when // when
@@ -366,7 +367,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true let backgroundRunning = true
let descendantAgent = "atlas" let descendantAgent = "atlas"
const promptAsyncMock = mock(async () => ({})) const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -384,18 +385,18 @@ describe("atlas background task retry", () => {
}), }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: (currentSessionID: string) => { getTasksByParentSession: (currentSessionID: string) => {
if (currentSessionID !== descendantSessionID) { if (currentSessionID !== descendantSessionID) {
return [] return []
} }
return backgroundRunning ? [{ status: "running" }] : [] return backgroundRunning ? [{ status: "running" }] : []
}, },
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & { }),
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}) })
// when // when
@@ -424,7 +425,7 @@ describe("atlas background task retry", () => {
const deferredPrompt = createDeferred<{}>() const deferredPrompt = createDeferred<{}>()
const promptAsyncMock = mock(() => deferredPrompt.promise) const promptAsyncMock = mock(() => deferredPrompt.promise)
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -432,7 +433,7 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput) }))
// when // when
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
@@ -462,7 +463,7 @@ describe("atlas background task retry", () => {
promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise) promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise)
promptAsyncMock.mockImplementationOnce(async () => ({})) promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -470,13 +471,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: () => [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }> getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}, }>({
getTasksByParentSession: () => [],
}),
}) })
// when // when
@@ -515,7 +516,7 @@ describe("atlas background task retry", () => {
}) })
promptAsyncMock.mockImplementationOnce(async () => ({})) promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir, directory: testDir,
client: { client: {
session: { session: {
@@ -523,13 +524,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
}, },
} as unknown as PluginInput, { }), {
directory: testDir, directory: testDir,
backgroundManager: { backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }> getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}, }>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
}) })
// when // when
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state" import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state"
import { injectBoulderContinuation } from "./boulder-continuation-injector" import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("injectBoulderContinuation", () => { describe("injectBoulderContinuation", () => {
beforeEach(() => { beforeEach(() => {
@@ -20,7 +21,7 @@ describe("injectBoulderContinuation", () => {
const promptAsyncMock = mock(async (_request: unknown) => undefined) const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] })) const messagesMock = mock(async () => ({ data: [] }))
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp", directory: "/tmp",
client: { client: {
session: { session: {
@@ -28,7 +29,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await injectBoulderContinuation({ const result = await injectBoulderContinuation({
@@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => {
const messagesMock = mock(async () => ({ data: [] })) const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 } const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 }
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp", directory: "/tmp",
client: { client: {
session: { session: {
@@ -68,7 +69,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await injectBoulderContinuation({ const result = await injectBoulderContinuation({
@@ -78,9 +79,9 @@ describe("injectBoulderContinuation", () => {
remaining: 1, remaining: 1,
total: 2, total: 2,
agent: "atlas", agent: "atlas",
backgroundManager: { backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "running" }], getTasksByParentSession: () => [{ status: "running" }],
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"], }),
sessionState, sessionState,
}) })
@@ -98,7 +99,7 @@ describe("injectBoulderContinuation", () => {
const messagesMock = mock(async () => ({ data: [] })) const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 } const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 }
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp", directory: "/tmp",
client: { client: {
session: { session: {
@@ -106,7 +107,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await injectBoulderContinuation({ const result = await injectBoulderContinuation({
@@ -116,9 +117,9 @@ describe("injectBoulderContinuation", () => {
remaining: 1, remaining: 1,
total: 2, total: 2,
agent: "atlas", agent: "atlas",
backgroundManager: { backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "pending" }], getTasksByParentSession: () => [{ status: "pending" }],
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"], }),
sessionState, sessionState,
}) })
@@ -134,7 +135,7 @@ describe("injectBoulderContinuation", () => {
const promptAsyncMock = mock(async (_request: unknown) => undefined) const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] })) const messagesMock = mock(async () => ({ data: [] }))
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp", directory: "/tmp",
client: { client: {
session: { session: {
@@ -142,7 +143,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await injectBoulderContinuation({ const result = await injectBoulderContinuation({
@@ -189,7 +190,7 @@ describe("injectBoulderContinuation", () => {
}], }],
})) }))
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp", directory: "/tmp",
client: { client: {
session: { session: {
@@ -197,7 +198,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await injectBoulderContinuation({ const result = await injectBoulderContinuation({
+12 -11
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error" import { isAbortError } from "./is-abort-error"
import { handleAtlasSessionIdle } from "./idle-event" import { handleAtlasSessionIdle } from "./idle-event"
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const state = getState(sessionID) const state = getState(sessionID)
@@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return return
@@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined const role = info?.role as string | undefined
if (!sessionID) return if (!sessionID) return
@@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined const role = info?.role as string | undefined
if (sessionID && role === "assistant") { if (sessionID && role === "assistant") {
@@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(props)
if (sessionID) { if (sessionID) {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (state) { if (state) {
@@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
const deletedState = sessions.get(sessionInfo.id) const deletedState = sessions.get(sessionID)
if (deletedState?.pendingRetryTimer) { if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer) clearTimeout(deletedState.pendingRetryTimer)
} }
sessions.delete(sessionInfo.id) sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
} }
return return
} }
if (event.type === "session.compacted") { 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) { if (sessionID) {
const compactedState = sessions.get(sessionID) const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) { if (compactedState?.pendingRetryTimer) {
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const { createAtlasHook } = await import("./index") const { createAtlasHook } = await import("./index")
@@ -49,7 +50,7 @@ describe("atlas hook idle-event complete boulder", () => {
}, },
}) })
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory, directory: testDirectory,
client: { client: {
session: { session: {
@@ -59,7 +60,7 @@ describe("atlas hook idle-event complete boulder", () => {
promptAsync: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }),
}, },
}, },
} as unknown as Parameters<typeof createAtlasHook>[0]) }))
// when // when
await hook.handler({ await hook.handler({
+3 -2
View File
@@ -7,6 +7,7 @@ import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-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") const { createAtlasHook } = await import("./index")
@@ -32,7 +33,7 @@ describe("atlas hook idle-event session lineage", () => {
} }
function createHook(parentSessionIDs?: Record<string, string | undefined>) { function createHook(parentSessionIDs?: Record<string, string | undefined>) {
return createAtlasHook({ return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory, directory: testDirectory,
client: { client: {
session: { session: {
@@ -52,7 +53,7 @@ describe("atlas hook idle-event session lineage", () => {
}, },
}, },
}, },
} as unknown as Parameters<typeof createAtlasHook>[0]) }))
} }
beforeEach(() => { beforeEach(() => {
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-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_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
@@ -58,7 +59,7 @@ describe("atlas hook idle-event persisted lineage", () => {
parentSessionIDs?: Record<string, string | undefined>, parentSessionIDs?: Record<string, string | undefined>,
messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>, messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>,
) { ) {
return createAtlasHook({ return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory, directory: testDirectory,
client: { client: {
session: { session: {
@@ -79,7 +80,7 @@ describe("atlas hook idle-event persisted lineage", () => {
}, },
}, },
}, },
} as unknown as Parameters<typeof createAtlasHook>[0]) }))
} }
beforeEach(() => { beforeEach(() => {
@@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => {
}, },
}) })
const hook = createAtlasHook({ const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory, directory: testDirectory,
client: { client: {
session: { session: {
@@ -193,7 +194,7 @@ describe("atlas hook idle-event persisted lineage", () => {
}, },
}, },
}, },
} as unknown as Parameters<typeof createAtlasHook>[0]) }))
// when // when
await hook.handler({ await hook.handler({
+3 -2
View File
@@ -8,6 +8,7 @@ import { createBoulderState, readBoulderState, writeBoulderState } from "../../f
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import { handleAtlasSessionIdle } from "./idle-event" import { handleAtlasSessionIdle } from "./idle-event"
import type { SessionState } from "./types" import type { SessionState } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("handleAtlasSessionIdle completion nudge", () => { describe("handleAtlasSessionIdle completion nudge", () => {
const SESSION_ID = "session-main-1" const SESSION_ID = "session-main-1"
@@ -76,14 +77,14 @@ describe("handleAtlasSessionIdle completion nudge", () => {
return { data: {} } return { data: {} }
}) })
const ctx = { const ctx = unsafeTestValue<PluginInput>({
directory: testDirectory, directory: testDirectory,
client: { client: {
session: { session: {
promptAsync: promptAsyncMock, promptAsync: promptAsyncMock,
}, },
}, },
} as unknown as PluginInput })
const sessionStateById = new Map<string, SessionState>() const sessionStateById = new Map<string, SessionState>()
const getState = (sessionId: string): SessionState => { const getState = (sessionId: string): SessionState => {
+32
View File
@@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999
expect(callArgs.body.parts[0].text).toContain("2 remaining") 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 () => { test("should settle idle before injecting boulder continuation", async () => {
// given // given
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")
@@ -1,11 +1,12 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver" import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("resolveRecentPromptContextForSession", () => { describe("resolveRecentPromptContextForSession", () => {
test("uses message time.created rather than SDK array order for recent prompt context", async () => { test("uses message time.created rather than SDK array order for recent prompt context", async () => {
// given // given
const ctx = { const ctx = unsafeTestValue<PluginInput>({
client: { client: {
session: { session: {
messages: mock(async () => ({ messages: mock(async () => ({
@@ -32,7 +33,7 @@ describe("resolveRecentPromptContextForSession", () => {
})), })),
}, },
}, },
} as unknown as PluginInput })
// when // when
const result = await resolveRecentPromptContextForSession(ctx, "ses_123") const result = await resolveRecentPromptContextForSession(ctx, "ses_123")
@@ -8,6 +8,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { Project } from "@opencode-ai/sdk" import type { Project } from "@opencode-ai/sdk"
import { readBoulderState, writeBoulderState } from "../../features/boulder-state" import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { createToolExecuteBeforeHandler } from "./tool-execute-before" import { createToolExecuteBeforeHandler } from "./tool-execute-before"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const isCallerOrchestratorMock = mock(async () => true) const isCallerOrchestratorMock = mock(async () => true)
const collectGitDiffStatsMock = mock(() => ({ const collectGitDiffStatsMock = mock(() => ({
@@ -80,11 +81,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
function createHandler(parentSessionIDs?: Record<string, string | undefined>) { function createHandler(parentSessionIDs?: Record<string, string | undefined>) {
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
}, },
} as unknown as PluginInput["client"] })
if (parentSessionIDs) { if (parentSessionIDs) {
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
@@ -141,11 +142,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child123" const childSessionID = "ses_child123"
const planPath = join(testDirectory, "background-launch-plan.md") const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async () => createSessionGetResult(undefined), get: async () => createSessionGetResult(undefined),
}, },
} as unknown as PluginInput["client"] })
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined), createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -215,11 +216,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child_lookup_failure" const childSessionID = "ses_child_lookup_failure"
const planPath = join(testDirectory, "background-launch-plan.md") const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async () => createSessionGetResult(undefined), get: async () => createSessionGetResult(undefined),
}, },
} as unknown as PluginInput["client"] })
spyOn(client.session, "get").mockImplementation((input) => { spyOn(client.session, "get").mockImplementation((input) => {
if (input?.path?.id === childSessionID) { if (input?.path?.id === childSessionID) {
@@ -288,11 +289,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_outside_lineage" const childSessionID = "ses_outside_lineage"
const planPath = join(testDirectory, "background-launch-plan.md") const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async () => createSessionGetResult(undefined), get: async () => createSessionGetResult(undefined),
}, },
} as unknown as PluginInput["client"] })
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined), createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined),
@@ -358,11 +359,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_unrelated_child" const childSessionID = "ses_unrelated_child"
const planPath = join(testDirectory, "background-launch-plan.md") const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async () => createSessionGetResult(undefined), get: async () => createSessionGetResult(undefined),
}, },
} as unknown as PluginInput["client"] })
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined), createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -431,11 +432,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const planPathA = join(testDirectory, "background-launch-work-a.md") const planPathA = join(testDirectory, "background-launch-work-a.md")
const planPathB = join(testDirectory, "background-launch-work-b.md") const planPathB = join(testDirectory, "background-launch-work-b.md")
const project = createProject() const project = createProject()
const client = { const client = unsafeTestValue<PluginInput["client"]>({
session: { session: {
get: async () => createSessionGetResult(undefined), get: async () => createSessionGetResult(undefined),
}, },
} as unknown as PluginInput["client"] })
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined), createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
+3 -11
View File
@@ -5,6 +5,7 @@ import {
} from "./detector" } from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor" import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { import {
AUTO_SLASH_COMMAND_TAG_CLOSE, AUTO_SLASH_COMMAND_TAG_CLOSE,
AUTO_SLASH_COMMAND_TAG_OPEN, AUTO_SLASH_COMMAND_TAG_OPEN,
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
function getDeletedSessionID(properties: unknown): string | null { function getDeletedSessionID(properties: unknown): string | null {
if (!isRecord(properties)) { return resolveSessionEventID(properties) ?? null
return null
}
const info = properties.info
if (!isRecord(info)) {
return null
}
return typeof info.id === "string" ? info.id : null
} }
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null { function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
"commandId", "commandId",
] ]
const recordInput = input as unknown const recordInput: unknown = input
if (!isRecord(recordInput)) { if (!isRecord(recordInput)) {
return null return null
} }
@@ -15,7 +15,7 @@ mock.module("../constants", () => ({
const current = mockState.candidates const current = mockState.candidates
// Forward array methods/properties to the mutable candidates list // Forward array methods/properties to the mutable candidates list
// so getCachedVersion's `for (... of ...)` sees fresh data per test. // so getCachedVersion's `for (... of ...)` sees fresh data per test.
const value = (current as unknown as Record<PropertyKey, unknown>)[prop] const value = (unsafeTestValue<Record<PropertyKey, unknown>>(current))[prop]
if (typeof value === "function") { if (typeof value === "function") {
return (value as (...args: unknown[]) => unknown).bind(current) return (value as (...args: unknown[]) => unknown).bind(current)
} }
@@ -29,6 +29,7 @@ mock.module("./package-json-locator", () => ({
})) }))
import { getCachedVersion } from "./cached-version" import { getCachedVersion } from "./cached-version"
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
describe("getCachedVersion (GH-3257)", () => { describe("getCachedVersion (GH-3257)", () => {
let cacheRoot: string let cacheRoot: string
+5 -5
View File
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names" import { getAgentConfigKey } from "../../shared/agent-display-names"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { buildReminderMessage } from "./formatter" import { buildReminderMessage } from "./formatter"
/** /**
@@ -120,15 +121,14 @@ export function createCategorySkillReminderHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
sessionStates.delete(sessionInfo.id) sessionStates.delete(sessionID)
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
if (sessionID) { if (sessionID) {
sessionStates.delete(sessionID) sessionStates.delete(sessionID)
} }
@@ -3,6 +3,7 @@ import { createCategorySkillReminderHook } from "./index"
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("category-skill-reminder hook", () => { describe("category-skill-reminder hook", () => {
let logCalls: Array<{ msg: string; data?: unknown }> let logCalls: Array<{ msg: string; data?: unknown }>
@@ -21,13 +22,13 @@ describe("category-skill-reminder hook", () => {
}) })
function createMockPluginInput() { function createMockPluginInput() {
return { return unsafeTestValue({
client: { client: {
tui: { tui: {
showToast: async () => {}, showToast: async () => {},
}, },
}, },
} as any })
} }
function createHook(availableSkills: AvailableSkill[] = []) { function createHook(availableSkills: AvailableSkill[] = []) {
+2 -1
View File
@@ -3,6 +3,7 @@ import { join } from "path"
import type { ClaudeHookEvent } from "./types" import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getOpenCodeConfigDir } from "../../shared" import { getOpenCodeConfigDir } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
const CONFIG_CACHE_TTL_MS = 30_000 const CONFIG_CACHE_TTL_MS = 30_000
@@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig |
} }
try { try {
const content = await Bun.file(path).text() const content = await bunFile(path).text()
return JSON.parse(content) as PluginExtendedConfig return JSON.parse(content) as PluginExtendedConfig
} catch (error) { } catch (error) {
log("Failed to load config", { path, error }) log("Failed to load config", { path, error })
+2 -1
View File
@@ -1,6 +1,7 @@
import { join } from "path" import { join } from "path"
import { existsSync } from "fs" import { existsSync } from "fs"
import { getClaudeConfigDir } from "../../shared" import { getClaudeConfigDir } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types" import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
const CONFIG_CACHE_TTL_MS = 30_000 const CONFIG_CACHE_TTL_MS = 30_000
@@ -126,7 +127,7 @@ export async function loadClaudeHooksConfig(
for (const settingsPath of paths) { for (const settingsPath of paths) {
if (existsSync(settingsPath)) { if (existsSync(settingsPath)) {
try { try {
const content = await Bun.file(settingsPath).text() const content = await bunFile(settingsPath).text()
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig } const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
if (settings.hooks) { if (settings.hooks) {
const normalizedHooks = normalizeHooksConfig(settings.hooks) const normalizedHooks = normalizeHooksConfig(settings.hooks)
@@ -3,6 +3,7 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types" import type { HookHttp } from "./types"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockFetch = mock(() => const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -31,7 +32,7 @@ describe("executeHttpHook TLS security", () => {
let logCalls: Array<{ message: string; data?: unknown }> let logCalls: Array<{ message: string; data?: unknown }>
beforeEach(() => { beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
mockFetch.mockReset() mockFetch.mockReset()
mockFetch.mockImplementation(() => mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -1,5 +1,6 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types" import type { HookHttp } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockFetch = mock(() => const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -9,7 +10,7 @@ const originalFetch = globalThis.fetch
describe("executeHttpHook", () => { describe("executeHttpHook", () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
mockFetch.mockReset() mockFetch.mockReset()
mockFetch.mockImplementation(() => mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -33,7 +34,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, stdinData) await executeHttpHook(hook, stdinData)
expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [url, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") expect(url).toBe("http://localhost:8080/hooks/pre-tool-use")
expect(options.method).toBe("POST") expect(options.method).toBe("POST")
expect(options.body).toBe(stdinData) expect(options.body).toBe(stdinData)
@@ -44,7 +45,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, stdinData) await executeHttpHook(hook, stdinData)
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string> const headers = options.headers as Record<string, string>
expect(headers["Content-Type"]).toBe("application/json") expect(headers["Content-Type"]).toBe("application/json")
}) })
@@ -72,7 +73,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}") await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string> const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123") expect(headers["Authorization"]).toBe("Bearer secret-123")
}) })
@@ -88,7 +89,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}") await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string> const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123") expect(headers["Authorization"]).toBe("Bearer secret-123")
}) })
@@ -104,7 +105,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}") await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string> const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer ") expect(headers["Authorization"]).toBe("Bearer ")
}) })
@@ -121,7 +122,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}") await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
expect(options.signal).toBeDefined() expect(options.signal).toBeDefined()
}) })
}) })
@@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript"
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache" import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
import type { PluginConfig } from "../types" import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import { import {
clearAllSessionHookState, clearAllSessionHookState,
clearSessionHookState, clearSessionHookState,
@@ -26,7 +27,7 @@ export function createSessionEventHandler(
if (event.type === "session.error") { if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (sessionID) { if (sessionID) {
sessionErrorState.set(sessionID, { sessionErrorState.set(sessionID, {
hasError: true, hasError: true,
@@ -38,13 +39,13 @@ export function createSessionEventHandler(
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
parentSessionIdCache.delete(sessionInfo.id) parentSessionIdCache.delete(sessionID)
clearTranscriptCache(sessionInfo.id) clearTranscriptCache(sessionID)
clearToolInputCache(sessionInfo.id) clearToolInputCache(sessionID)
contextCollector?.clear(sessionInfo.id) contextCollector?.clear(sessionID)
clearSessionHookState(sessionInfo.id) clearSessionHookState(sessionID)
} }
return return
} }
@@ -54,7 +55,7 @@ export function createSessionEventHandler(
} }
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig() const claudeConfig = await loadClaudeHooksConfig()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("tool-input-cache", () => { describe("tool-input-cache", () => {
const originalSetInterval = globalThis.setInterval 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 () => { test("#given cleanup timer started #when stop cleanup runs #then interval is cleared and cache is emptied", async () => {
//#given //#given
const intervalHandle = { unref: mock(() => {}) } as unknown as ReturnType<typeof setInterval> const intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: mock(() => {}) })
const setIntervalMock = mock(() => intervalHandle) const setIntervalMock = mock(() => intervalHandle)
const clearIntervalMock = mock(() => {}) const clearIntervalMock = mock(() => {})
globalThis.setInterval = setIntervalMock as unknown as typeof setInterval globalThis.setInterval = unsafeTestValue<typeof setInterval>(setIntervalMock)
globalThis.clearInterval = clearIntervalMock as unknown as typeof clearInterval globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(clearIntervalMock)
const modulePath = new URL("./tool-input-cache.ts", import.meta.url).pathname const modulePath = new URL("./tool-input-cache.ts", import.meta.url).pathname
const cacheModule = await import(`${modulePath}?stop-clear`) const cacheModule = await import(`${modulePath}?stop-clear`)
+3 -2
View File
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os"
import { processWithCli } from "./cli-runner" import { processWithCli } from "./cli-runner"
import type { PendingCall } from "./types" import type { PendingCall } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createMockInput() { function createMockInput() {
return { return {
@@ -74,7 +75,7 @@ done
const originalSetTimeout = globalThis.setTimeout const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn() fn()
return 0 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout }) as typeof setTimeout
try { try {
@@ -102,7 +103,7 @@ done
const originalSetTimeout = globalThis.setTimeout const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn() fn()
return 0 as unknown as ReturnType<typeof setTimeout> return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout }) as typeof setTimeout
try { try {
+12 -11
View File
@@ -1,4 +1,5 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("pending-calls cleanup interval", () => { describe("pending-calls cleanup interval", () => {
test("starts cleanup once and unrefs timer", async () => { test("starts cleanup once and unrefs timer", async () => {
@@ -7,18 +8,18 @@ describe("pending-calls cleanup interval", () => {
const setIntervalCalls: number[] = [] const setIntervalCalls: number[] = []
let unrefCalled = 0 let unrefCalled = 0
globalThis.setInterval = (( globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
_handler: TimerHandler, _handler: TimerHandler,
timeout?: number, timeout?: number,
..._args: any[] ..._args: unknown[]
) => { ) => {
setIntervalCalls.push(timeout as number) setIntervalCalls.push(timeout as number)
return { return unsafeTestValue<ReturnType<typeof setInterval>>({
unref: () => { unref: () => {
unrefCalled += 1 unrefCalled += 1
}, },
} as unknown as ReturnType<typeof setInterval> })
}) as unknown as typeof setInterval }))
try { try {
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
@@ -43,20 +44,20 @@ describe("pending-calls cleanup interval", () => {
let intervalHandle: ReturnType<typeof setInterval> | undefined let intervalHandle: ReturnType<typeof setInterval> | undefined
let clearCalls = 0 let clearCalls = 0
globalThis.setInterval = (( globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
_handler: TimerHandler, _handler: TimerHandler,
_timeout?: number, _timeout?: number,
..._args: any[] ..._args: unknown[]
) => { ) => {
intervalHandle = { unref: () => {} } as unknown as ReturnType<typeof setInterval> intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: () => {} })
return intervalHandle return intervalHandle
}) as unknown as typeof setInterval }))
globalThis.clearInterval = ((handle?: ReturnType<typeof setInterval>) => { globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(((handle?: ReturnType<typeof setInterval>) => {
if (handle === intervalHandle) { if (handle === intervalHandle) {
clearCalls += 1 clearCalls += 1
} }
}) as unknown as typeof clearInterval }))
try { try {
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
@@ -3,6 +3,7 @@ import {
clearCompactionAgentConfigCheckpoint, clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint" } from "../../shared/compaction-agent-config-checkpoint"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt" import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver" import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
@@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: {
sessionID?: string sessionID?: string
} | undefined } | undefined
if (!info?.sessionID || info.role !== "assistant" || !info.id) { const sessionID = resolveMessageEventSessionID(props)
if (!sessionID || info?.role !== "assistant" || !info.id) {
return return
} }
const tailState = getTailState(info.sessionID) const tailState = getTailState(sessionID)
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) { if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
finalizeTrackedAssistantMessage(tailState) finalizeTrackedAssistantMessage(tailState)
await maybeWarnAboutNoTextTail(info.sessionID) await maybeWarnAboutNoTextTail(sessionID)
} }
if (tailState.currentMessageID !== info.id) { if (tailState.currentMessageID !== info.id) {
@@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: {
} }
if (event.type === "message.part.delta") { 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 messageID = props?.messageID as string | undefined
const field = props?.field as string | undefined const field = props?.field as string | undefined
const delta = props?.delta as string | undefined const delta = props?.delta as string | undefined
@@ -1,8 +1,9 @@
import { resolveSessionEventID } from "../../shared/event-session-id"
export function isCompactionAgent(agent: string | undefined): boolean { export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction" return agent?.trim().toLowerCase() === "compaction"
} }
export function resolveSessionID(props?: Record<string, unknown>): string | undefined { export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ?? return resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
} }
+2 -2
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
interface TodoSnapshot { interface TodoSnapshot {
@@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
} }
function resolveSessionID(props?: Record<string, unknown>): string | undefined { function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ?? return resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
} }
export interface CompactionTodoPreserver { export interface CompactionTodoPreserver {
+8 -6
View File
@@ -4,6 +4,7 @@ import {
type ContextLimitModelCacheState, type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver" } from "../shared/context-limit-resolver"
import { isCompactionAgent } from "../shared/compaction-marker" import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const CONTEXT_WARNING_THRESHOLD = 0.70 const CONTEXT_WARNING_THRESHOLD = 0.70
@@ -86,10 +87,10 @@ export function createContextWindowMonitorHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
remindedSessions.delete(sessionInfo.id) remindedSessions.delete(sessionID)
tokenCache.delete(sessionInfo.id) tokenCache.delete(sessionID)
} }
} }
@@ -106,9 +107,10 @@ export function createContextWindowMonitorHook(
if (!info || info.role !== "assistant" || !info.finish) return if (!info || info.role !== "assistant" || !info.finish) return
if (isCompactionAgent(info.agent)) 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, providerID: info.providerID,
modelID: info.modelID ?? "", modelID: info.modelID ?? "",
tokens: info.tokens, tokens: info.tokens,
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForAgentsInjection } from "./injector"; import { processFilePathForAgentsInjection } from "./injector";
import { clearInjectedPaths } from "./storage"; import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
sessionCaches.delete(sessionInfo.id); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionInfo.id); clearInjectedPaths(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
sessionCaches.delete(sessionID); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID); clearInjectedPaths(sessionID);
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForReadmeInjection } from "./injector"; import { processFilePathForReadmeInjection } from "./injector";
import { clearInjectedPaths } from "./storage"; import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
sessionCaches.delete(sessionInfo.id); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionInfo.id); clearInjectedPaths(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
sessionCaches.delete(sessionID); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID); clearInjectedPaths(sessionID);
+3 -2
View File
@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach } from "bun:test" import { describe, it, expect, beforeEach } from "bun:test"
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index" import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("createEditErrorRecoveryHook", () => { describe("createEditErrorRecoveryHook", () => {
let hook: ReturnType<typeof createEditErrorRecoveryHook> let hook: ReturnType<typeof createEditErrorRecoveryHook>
beforeEach(() => { beforeEach(() => {
hook = createEditErrorRecoveryHook({} as any) hook = createEditErrorRecoveryHook(unsafeTestValue({}))
}) })
describe("tool.execute.after", () => { describe("tool.execute.after", () => {
@@ -108,7 +109,7 @@ describe("createEditErrorRecoveryHook", () => {
const input = createInput("Edit") const input = createInput("Edit")
const output = { const output = {
title: "Edit", title: "Edit",
output: undefined as unknown as string, output: unsafeTestValue<string>(undefined),
metadata: {}, metadata: {},
} }
@@ -1,4 +1,5 @@
import { log } from "../../shared" import { log } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils" import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
interface HashlineEditDiffEnhancerConfig { interface HashlineEditDiffEnhancerConfig {
@@ -38,7 +39,7 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
async function captureOldContent(filePath: string): Promise<string> { async function captureOldContent(filePath: string): Promise<string> {
try { try {
const file = Bun.file(filePath) const file = bunFile(filePath)
if (await file.exists()) { if (await file.exists()) {
return await file.text() return await file.text()
} }
@@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
let newContent: string let newContent: string
try { try {
newContent = await Bun.file(filePath).text() newContent = await bunFile(filePath).text()
} catch { } catch {
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath }) log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
return return
+2 -1
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { bunFile } from "../../shared/bun-file-shim"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation" import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
const WRITE_SUCCESS_MARKER = "File written successfully." const WRITE_SUCCESS_MARKER = "File written successfully."
@@ -178,7 +179,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
return return
} }
const file = Bun.file(filePath) const file = bunFile(filePath)
if (!(await file.exists())) { if (!(await file.exists())) {
return return
} }
+2 -2
View File
@@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types";
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
import { subagentSessions } from "../../features/claude-code-session-state"; import { subagentSessions } from "../../features/claude-code-session-state";
import { resolveSessionEventID } from "../../shared/event-session-id";
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string; tool: string;
@@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
const sessionID = sessionInfo?.id;
if (sessionID) { if (sessionID) {
const state = getOrCreateStateLocal(sessionID); const state = getOrCreateStateLocal(sessionID);
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { createKeywordDetectorHook } from "./index" import { createKeywordDetectorHook } from "./index"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type StartLoopCall = { type StartLoopCall = {
sessionID: string sessionID: string
@@ -11,13 +12,13 @@ type StartLoopCall = {
type CancelLoopCall = { sessionID: string } type CancelLoopCall = { sessionID: string }
function createMockPluginInput() { function createMockPluginInput() {
return { return unsafeTestValue({
client: { client: {
tui: { tui: {
showToast: async () => {}, showToast: async () => {},
}, },
}, },
} as any })
} }
function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) { function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) {
@@ -4,6 +4,7 @@ import { createKeywordDetectorHook } from "./index"
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state" import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state" import * as sessionState from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("keyword-detector hyperplan-ultrawork combo", () => { describe("keyword-detector hyperplan-ultrawork combo", () => {
let logSpy: ReturnType<typeof spyOn> let logSpy: ReturnType<typeof spyOn>
@@ -22,7 +23,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => {
function createMockPluginInput(options: { toastCalls?: string[] } = {}) { function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
const toastCalls = options.toastCalls ?? [] const toastCalls = options.toastCalls ?? []
return { return unsafeTestValue<PluginInput>({
client: { client: {
tui: { tui: {
showToast: async (opts: { body: { title: string } }) => { showToast: async (opts: { body: { title: string } }) => {
@@ -30,7 +31,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => {
}, },
}, },
}, },
} as unknown as PluginInput })
} }
test("should inject combo message when user types 'hpp ulw' (forward order)", async () => { test("should inject combo message when user types 'hpp ulw' (forward order)", async () => {
+5 -4
View File
@@ -7,6 +7,7 @@ import { setMainSession, updateSessionAgent, clearSessionAgent, _resetForTesting
import { ContextCollector } from "../../features/context-injector" import { ContextCollector } from "../../features/context-injector"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state" import * as sessionState from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type ToastOptions = { body: { title: string } } type ToastOptions = { body: { title: string } }
@@ -881,13 +882,13 @@ describe("keyword-detector team mode", () => {
}) })
function createMockPluginInput() { function createMockPluginInput() {
return { return unsafeTestValue<PluginInput>({
client: { client: {
tui: { tui: {
showToast: async () => {}, showToast: async () => {},
}, },
}, },
} as unknown as PluginInput })
} }
test("should inject team-mode message when user types 'team mode'", async () => { test("should inject team-mode message when user types 'team mode'", async () => {
@@ -1063,7 +1064,7 @@ describe("keyword-detector disabled_keywords config", () => {
function createMockPluginInput(options: { toastCalls?: string[] } = {}) { function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
const toastCalls = options.toastCalls ?? [] const toastCalls = options.toastCalls ?? []
return { return unsafeTestValue<PluginInput>({
client: { client: {
tui: { tui: {
showToast: async (opts: { body: { title: string } }) => { showToast: async (opts: { body: { title: string } }) => {
@@ -1071,7 +1072,7 @@ describe("keyword-detector disabled_keywords config", () => {
}, },
}, },
}, },
} as unknown as PluginInput })
} }
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => { test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
@@ -3,6 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createKeywordDetectorHook } from "./index" import { createKeywordDetectorHook } from "./index"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type StartLoopCall = { type StartLoopCall = {
sessionID: string sessionID: string
@@ -11,7 +12,7 @@ type StartLoopCall = {
} }
function createMockPluginInput(toastCalls: string[] = []) { function createMockPluginInput(toastCalls: string[] = []) {
return { return unsafeTestValue<PluginInput>({
client: { client: {
tui: { tui: {
showToast: async (opts: { body: { title: string } }) => { showToast: async (opts: { body: { title: string } }) => {
@@ -19,7 +20,7 @@ function createMockPluginInput(toastCalls: string[] = []) {
}, },
}, },
}, },
} as unknown as PluginInput })
} }
function createMockRalphLoop(startLoopCalls: StartLoopCall[]) { function createMockRalphLoop(startLoopCalls: StartLoopCall[]) {
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createKeywordDetectorHook } from "./index" import { createKeywordDetectorHook } from "./index"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createMockPluginInput(toastMessages: string[]) { function createMockPluginInput(toastMessages: string[]) {
return { return unsafeTestValue({
client: { client: {
tui: { tui: {
showToast: async (opts: { body: { message: string } }) => { showToast: async (opts: { body: { message: string } }) => {
@@ -11,7 +12,7 @@ function createMockPluginInput(toastMessages: string[]) {
}, },
}, },
}, },
} as any })
} }
describe("keyword-detector ultrawork runtime variant gating", () => { describe("keyword-detector ultrawork runtime variant gating", () => {
+21 -20
View File
@@ -1,3 +1,4 @@
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
declare const require: (name: string) => any declare const require: (name: string) => any
const { beforeEach, describe, expect, mock, test, afterAll } = require("bun:test") const { beforeEach, describe, expect, mock, test, afterAll } = require("bun:test")
@@ -86,12 +87,12 @@ describe("model fallback hook", () => {
}) })
test("applies pending fallback on chat.message by overriding model", async () => { test("applies pending fallback on chat.message by overriding model", async () => {
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
const set = setPendingModelFallback( const set = setPendingModelFallback(
modelFallback, modelFallback,
@@ -122,12 +123,12 @@ describe("model fallback hook", () => {
}) })
test("preserves fallback progression across repeated session.error retries", async () => { test("preserves fallback progression across repeated session.error retries", async () => {
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
const sessionID = "ses_model_fallback_main" const sessionID = "ses_model_fallback_main"
expect( expect(
@@ -212,12 +213,12 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_noop_skip" const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(modelFallback, sessionID) clearPendingModelFallback(modelFallback, sessionID)
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
setSessionFallbackChain(modelFallback, sessionID, [ setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-7" }, { providers: ["anthropic"], model: "claude-opus-4-7" },
@@ -254,12 +255,12 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_noop_variant_skip" const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(modelFallback, sessionID) clearPendingModelFallback(modelFallback, sessionID)
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
setSessionFallbackChain(modelFallback, sessionID, [ setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" }, { providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
@@ -299,12 +300,12 @@ describe("model fallback hook", () => {
clearPendingModelFallback(modelFallback, sessionID) clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"]) readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
setSessionFallbackChain(modelFallback, sessionID, [ setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["provider-y"], model: "fallback-model" }, { providers: ["provider-y"], model: "fallback-model" },
@@ -355,16 +356,16 @@ describe("model fallback hook", () => {
test("shows toast when fallback is applied", async () => { test("shows toast when fallback is applied", async () => {
const toastCalls: Array<{ title: string; message: string }> = [] const toastCalls: Array<{ title: string; message: string }> = []
const hook = createModelFallbackHook({ const hook = unsafeTestValue<{
toast: async ({ title, message }) => {
toastCalls.push({ title, message })
},
}) as unknown as {
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(createModelFallbackHook({
toast: async ({ title, message }) => {
toastCalls.push({ title, message })
},
}))
const set = setPendingModelFallback( const set = setPendingModelFallback(
hook, hook,
@@ -393,12 +394,12 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_ghcp" const sessionID = "ses_model_fallback_ghcp"
clearPendingModelFallback(modelFallback, sessionID) clearPendingModelFallback(modelFallback, sessionID)
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
setSessionFallbackChain(modelFallback, sessionID, [ setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" }, { providers: ["github-copilot"], model: "claude-sonnet-4-6" },
@@ -434,12 +435,12 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_google" const sessionID = "ses_model_fallback_google"
clearPendingModelFallback(modelFallback, sessionID) clearPendingModelFallback(modelFallback, sessionID)
const hook = modelFallback as unknown as { const hook = unsafeTestValue<{
"chat.message"?: ( "chat.message"?: (
input: { sessionID: string }, input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }, output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void> ) => Promise<void>
} }>(modelFallback)
setSessionFallbackChain(modelFallback, sessionID, [ setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" }, { providers: ["google"], model: "gemini-3.1-pro-preview" },
+11 -10
View File
@@ -4,6 +4,7 @@ import { describe, expect, spyOn, test } from "bun:test"
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentDisplayName } from "../../shared/agent-display-names"
import { createNoHephaestusNonGptHook } from "./index" import { createNoHephaestusNonGptHook } from "./index"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
@@ -19,9 +20,9 @@ describe("no-hephaestus-non-gpt hook", () => {
test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => { test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => {
// given - hephaestus with claude model // given - hephaestus with claude model
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook(unsafeTestValue({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) }))
const output1 = createOutput() const output1 = createOutput()
const output2 = createOutput() const output2 = createOutput()
@@ -54,9 +55,9 @@ describe("no-hephaestus-non-gpt hook", () => {
test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => { 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 // given - hephaestus with claude model and opt-out enabled
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook(unsafeTestValue({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any, { }), {
allowNonGptModel: true, allowNonGptModel: true,
}) })
@@ -83,9 +84,9 @@ describe("no-hephaestus-non-gpt hook", () => {
test("does not show toast when hephaestus uses gpt model", async () => { test("does not show toast when hephaestus uses gpt model", async () => {
// given - hephaestus with gpt model // given - hephaestus with gpt model
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook(unsafeTestValue({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) }))
const output = createOutput() const output = createOutput()
@@ -104,9 +105,9 @@ describe("no-hephaestus-non-gpt hook", () => {
test("does not show toast for non-hephaestus agent", async () => { test("does not show toast for non-hephaestus agent", async () => {
// given - sisyphus with claude model (non-gpt) // given - sisyphus with claude model (non-gpt)
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook(unsafeTestValue({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) }))
const output = createOutput() const output = createOutput()
@@ -127,9 +128,9 @@ describe("no-hephaestus-non-gpt hook", () => {
_resetForTesting() _resetForTesting()
updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY)
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook(unsafeTestValue({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) }))
const output = createOutput() const output = createOutput()
+3 -2
View File
@@ -5,6 +5,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentDisplayName } from "../../shared/agent-display-names"
import { createNoSisyphusGptHook } from "./index" import { createNoSisyphusGptHook } from "./index"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
@@ -22,9 +23,9 @@ function createOutput(): HookOutput {
} }
function createHookContext(showToast: (input: unknown) => Promise<unknown>): PluginInput { function createHookContext(showToast: (input: unknown) => Promise<unknown>): PluginInput {
return { return unsafeTestValue<PluginInput>({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as unknown as PluginInput })
} }
describe("no-sisyphus-gpt hook", () => { describe("no-sisyphus-gpt hook", () => {
+8 -7
View File
@@ -1,5 +1,6 @@
import type { OhMyOpenCodeConfig } from "../config" import type { OhMyOpenCodeConfig } from "../config"
import { isCompactionAgent } from "../shared/compaction-marker" import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
@@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionID = (props?.info as { id?: string } | undefined)?.id const sessionID = resolveSessionEventID(props)
if (sessionID) { if (sessionID) {
compactionInProgress.delete(sessionID) compactionInProgress.delete(sessionID)
compactedSessions.delete(sessionID) compactedSessions.delete(sessionID)
@@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook(
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID as string | undefined) const sessionID = resolveSessionEventID(props)
?? (props?.info as { id?: string } | undefined)?.id
if (sessionID) { if (sessionID) {
postCompactionMonitor.onSessionCompacted(sessionID) postCompactionMonitor.onSessionCompacted(sessionID)
} }
@@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook(
parts?: unknown parts?: unknown
} | undefined } | 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 (isCompactionAgent(info.agent)) return
if (info.providerID && info.tokens) { if (info.providerID && info.tokens) {
tokenCache.set(info.sessionID, { tokenCache.set(sessionID, {
providerID: info.providerID, providerID: info.providerID,
modelID: info.modelID ?? "", modelID: info.modelID ?? "",
tokens: info.tokens, tokens: info.tokens,
}) })
} }
compactedSessions.delete(info.sessionID) compactedSessions.delete(sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({ await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID, sessionID,
id: info.id, id: info.id,
parts: info.parts, parts: info.parts,
}) })
+6 -4
View File
@@ -41,6 +41,10 @@ function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs
}; };
} }
function hasQuestions(args: Record<string, unknown>): args is Record<string, unknown> & AskUserQuestionArgs {
return Array.isArray(args.questions);
}
export function createQuestionLabelTruncatorHook() { export function createQuestionLabelTruncatorHook() {
return { return {
"tool.execute.before": async ( "tool.execute.before": async (
@@ -50,10 +54,8 @@ export function createQuestionLabelTruncatorHook() {
const toolName = input.tool?.toLowerCase(); const toolName = input.tool?.toLowerCase();
if (toolName === "askuserquestion" || toolName === "ask_user_question") { if (toolName === "askuserquestion" || toolName === "ask_user_question") {
const args = output.args as unknown as AskUserQuestionArgs | undefined; if (hasQuestions(output.args)) {
const truncatedArgs = truncateQuestionLabels(output.args);
if (args?.questions) {
const truncatedArgs = truncateQuestionLabels(args);
Object.assign(output.args, truncatedArgs); Object.assign(output.args, truncatedArgs);
} }
} }

Some files were not shown because too many files have changed in this diff Show More