diff --git a/Cargo.lock b/Cargo.lock index 6c4a1a7a0..2c33d22d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "ast-grep-core", @@ -2399,7 +2399,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "15.7.6" +version = "15.8.0" dependencies = [ "async-trait", "libc", @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "arboard", @@ -2457,7 +2457,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "brush-builtins", diff --git a/Cargo.toml b/Cargo.toml index d2f80d436..f6ccba834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "15.7.6" +version = "15.8.0" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index f75e8c84b..deefdc60e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -30,9 +30,8 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { - "@anthropic-ai/sdk": "catalog:", "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", @@ -45,7 +44,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp": "src/cli.ts", }, @@ -85,7 +84,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -96,7 +95,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "15.7.6", + "version": "15.8.0", "bin": { "mnemopi": "src/cli.ts", }, @@ -113,7 +112,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "15.7.6", + "version": "15.8.0", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -121,7 +120,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp-stats": "./src/index.ts", }, @@ -146,7 +145,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp-swarm": "src/cli.ts", }, @@ -162,7 +161,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -203,7 +202,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "beautiful-mermaid": "catalog:", @@ -233,7 +232,6 @@ }, "catalog": { "@agentclientprotocol/sdk": "0.22.1", - "@anthropic-ai/sdk": "^0.99.0", "@babel/generator": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", @@ -244,15 +242,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.7.6", - "@oh-my-pi/omp-stats": "15.7.6", - "@oh-my-pi/pi-agent-core": "15.7.6", - "@oh-my-pi/pi-ai": "15.7.6", - "@oh-my-pi/pi-coding-agent": "15.7.6", - "@oh-my-pi/pi-mnemopi": "15.7.6", - "@oh-my-pi/pi-natives": "15.7.6", - "@oh-my-pi/pi-tui": "15.7.6", - "@oh-my-pi/pi-utils": "15.7.6", + "@oh-my-pi/hashline": "15.8.0", + "@oh-my-pi/omp-stats": "15.8.0", + "@oh-my-pi/pi-agent-core": "15.8.0", + "@oh-my-pi/pi-ai": "15.8.0", + "@oh-my-pi/pi-coding-agent": "15.8.0", + "@oh-my-pi/pi-mnemopi": "15.8.0", + "@oh-my-pi/pi-natives": "15.8.0", + "@oh-my-pi/pi-tui": "15.8.0", + "@oh-my-pi/pi-utils": "15.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", @@ -305,8 +303,6 @@ "packages": { "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.22.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.99.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-vdicFA9YjtvgpG8rxp39hqW4oxpkdRGPTq0QEts5TZhr2GkLozDduK/GiXrLQ7PzrKYBtIkQFdRW+QBjzlsABg=="], - "@anush008/tokenizers": ["@anush008/tokenizers@0.0.0", "", { "optionalDependencies": { "@anush008/tokenizers-darwin-universal": "0.0.0", "@anush008/tokenizers-linux-x64-gnu": "0.0.0", "@anush008/tokenizers-win32-x64-msvc": "0.0.0" } }, "sha512-IQD9wkVReKAhsEAbDjh/0KrBGTEXelqZLpOBRDaIRvlzZ9sjmUP+gKbpvzyJnei2JHQiE8JAgj7YcNloINbGBw=="], "@anush008/tokenizers-darwin-universal": ["@anush008/tokenizers-darwin-universal@0.0.0", "", { "os": "darwin" }, "sha512-SACpWEooTjFX89dFKRVUhivMxxcZRtA3nJGVepdLyrwTkQ1TZQ8581B5JoXp0TcTMHfgnDaagifvVoBiFEdNCQ=="], @@ -345,8 +341,6 @@ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], @@ -449,37 +443,37 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inquirer/ansi": ["@inquirer/ansi@2.0.6", "", {}, "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "@inquirer/checkbox": ["@inquirer/checkbox@5.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1HJt+3fqxblp/GQjdntSyoSHYBc0e3CzXVgjFpKA6qFLd9FHBBqwN8Co0xYH6t2JVUZrtFwZ4bBiwptkiLxyOg=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], - "@inquirer/confirm": ["@inquirer/confirm@6.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA=="], + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], - "@inquirer/core": ["@inquirer/core@11.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^4.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g=="], + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], "@inquirer/editor": ["@inquirer/editor@5.2.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/external-editor": "^3.0.1", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/m+sgRmzSdK6HDtVnl3PmI6MnZC4O+LLezedoJcrX7mINhTjjb0hlC7aEDGZXkFTB4b5uQ0q59AhYTah88KbNg=="], - "@inquirer/expand": ["@inquirer/expand@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fR7g4BVnIcs+4NApF6C5byflNM/EULxSxsv/2Jvg+gmop0R6eBIPvZqE6RYnTy1tQTFnf9wyHkwNoQSZbofaGA=="], + "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], "@inquirer/external-editor": ["@inquirer/external-editor@3.0.1", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tam+Gwjsxg2sx3iUVPkAnhKT/yrk2rd2NAa7XJU/J8OYpU0ifXsnp12xlvzp/DCpWBXVv+vLQsqnpAWwUcWD5Q=="], - "@inquirer/figures": ["@inquirer/figures@2.0.6", "", {}, "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw=="], + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], "@inquirer/input": ["@inquirer/input@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-sVZCz6P6e8tW5g2bSFel1oLpa6jK/u7BexFfrgTqR8syIdnHqy+iopnlSbYBZMsCK52chLjhGNBxt0eRqhsghw=="], - "@inquirer/number": ["@inquirer/number@4.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VMXB/XejCbaSTf9Xucl7dqjzzsaGsrs6XwSYXPbGZ2QbSuq/Gz8XamhSi9ClRubNXZlGry9xVg1tKkJdTDgCtQ=="], + "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], - "@inquirer/password": ["@inquirer/password@5.1.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5tqRuKCDIUxdPxTI/CuLnh914kz+WMPmURHKnZgui9gk43ebudEsdu4EwSn1CPSi5R+17YpBG+ba/YqTnRAcJA=="], + "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], "@inquirer/prompts": ["@inquirer/prompts@8.5.0", "", { "dependencies": { "@inquirer/checkbox": "^5.2.0", "@inquirer/confirm": "^6.1.0", "@inquirer/editor": "^5.2.0", "@inquirer/expand": "^5.1.0", "@inquirer/input": "^5.1.0", "@inquirer/number": "^4.1.0", "@inquirer/password": "^5.1.0", "@inquirer/rawlist": "^5.3.0", "@inquirer/search": "^4.2.0", "@inquirer/select": "^5.2.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-pLjXOnY4y3R1mgyHP3pXD/8eXejp+L/dde/0N2NLKgKfMstqhNZrpvs7Wkzbl9FYFQh10LRQ7QZwq+cz9rrhyw=="], - "@inquirer/rawlist": ["@inquirer/rawlist@5.3.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p+vAeTAD+cGXjGleP1F5LXrX2ISxNDZm+lqeBpnJausNLSZskZZkcggwhomqP8Igx9oIjnoeOrw98xvdFvdm2w=="], + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], - "@inquirer/search": ["@inquirer/search@4.2.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ByURoSGIaSl5O5Q0AmYmVmUsXbMUcBGNoA3FRL7TOyiA22IeFHymJKRkuILbOIlJwqnBk7AnPpseodyFUBzg+g=="], + "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], - "@inquirer/select": ["@inquirer/select@5.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6IzkcmEbEXfgVbxZ2d1UyJFbCBoc6dTofulFmrYuomIp88HXiVqRbqbg4/mbfZhvnNo6xYmnYo2AEmDof6fQkg=="], + "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], - "@inquirer/type": ["@inquirer/type@4.0.6", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g=="], + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -721,8 +715,6 @@ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], @@ -883,7 +875,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -943,8 +935,6 @@ "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1027,8 +1017,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], @@ -1119,7 +1107,7 @@ "music-metadata": ["music-metadata@11.12.3", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ=="], - "mute-stream": ["mute-stream@4.0.0", "", {}, "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -1241,13 +1229,11 @@ "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1271,8 +1257,6 @@ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], - "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], @@ -1403,6 +1387,8 @@ "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -1417,6 +1403,8 @@ "fastembed/onnxruntime-node/tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index c2a66ac52..f62b20155 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -68,5 +68,5 @@ use napi_derive::napi; /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV15_7_6")] +#[napi(js_name = "__piNativesV15_8_0")] pub const fn pi_natives_version_sentinel() {} diff --git a/docs/blob-artifact-architecture.md b/docs/blob-artifact-architecture.md index 7a3f2a19a..9233f3b90 100644 --- a/docs/blob-artifact-architecture.md +++ b/docs/blob-artifact-architecture.md @@ -76,7 +76,7 @@ Non-persistent sessions without an adopted manager can store `saveArtifact(...)` ### Agent output IDs (`agent://`) -`AgentOutputManager` allocates IDs for subagent outputs as `-` (optionally nested under parent prefix, e.g. `0-Parent.1-Child`). It scans existing `.md` files on initialization to continue from the next index on resume. +`AgentOutputManager` allocates IDs for subagent outputs from the requested name, used verbatim the first time and suffixed (`-2`, `-3`, …) only when the same name repeats (e.g. `Anna`, `Anna-2`). Nested outputs are grouped under the parent prefix (e.g. `Parent.Child`). It scans existing `.md` files on initialization so a resumed session never reuses a name that would clobber a prior output. ## Persistence dataflow diff --git a/docs/config-usage.md b/docs/config-usage.md index 130ea2223..f2d778214 100644 --- a/docs/config-usage.md +++ b/docs/config-usage.md @@ -217,7 +217,7 @@ Native provider (`id: native`) reads native config from: - Slash commands, rules, prompts, instructions, hooks, tools, extensions, extension modules, and settings use a project/user root only when the root directory exists and is non-empty. - Skills scan `/.omp/skills` for each ancestor from the current working directory up to the repo root/home boundary, plus `~/.omp/agent/skills`, without requiring the root `.omp` directory itself to be non-empty. -- `SYSTEM.md` and `AGENTS.md` read user-level files directly and use nearest-ancestor project `.omp` lookup for project files, but the project `.omp` directory must be non-empty. +- `SYSTEM.md` and `AGENTS.md` read user-level files directly and use nearest-ancestor project `.omp` lookup for project files, but the project `.omp` directory must be non-empty. See [`docs/system-prompt-customization.md`](./system-prompt-customization.md) for the full `SYSTEM.md` / `APPEND_SYSTEM.md` contract (replace vs. append, templating). ### Scope-specific loading diff --git a/docs/environment-variables.md b/docs/environment-variables.md index b8c4e527b..e63a14a00 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -111,7 +111,11 @@ When `CLAUDE_CODE_USE_FOUNDRY` is enabled, Anthropic requests switch to Foundry - Base URL resolves from `FOUNDRY_BASE_URL` (fallback remains model/default base URL if unset). - API key resolution for provider `anthropic` becomes: `ANTHROPIC_FOUNDRY_API_KEY` → `ANTHROPIC_OAUTH_TOKEN` → `ANTHROPIC_API_KEY`. -- `ANTHROPIC_CUSTOM_HEADERS` is parsed as comma/newline-separated `key: value` pairs and merged into request headers. +- `ANTHROPIC_CUSTOM_HEADERS` is parsed as comma/newline-separated `key: value` + pairs and merged into request headers. They are also forwarded when + `ANTHROPIC_BASE_URL` points to a non-Anthropic host (e.g. a corporate API + gateway), so enterprise gateways requiring proprietary auth headers work + without enabling Foundry mode. - TLS client/server material can be injected from env values: `NODE_EXTRA_CA_CERTS`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`. Each accepts either: @@ -123,7 +127,7 @@ When `CLAUDE_CODE_USE_FOUNDRY` is enabled, Anthropic requests switch to Foundry | `CLAUDE_CODE_USE_FOUNDRY` | Boolean-like string (`1`, `true`, `yes`, `on`) | Enables Foundry mode for Anthropic provider | | `FOUNDRY_BASE_URL` | URL string | Anthropic endpoint base URL in Foundry mode | | `ANTHROPIC_FOUNDRY_API_KEY` | Token string | Used for `Authorization: Bearer ` | -| `ANTHROPIC_CUSTOM_HEADERS` | Header list string | Extra headers; format `header-a: value, header-b: value` or newline-separated | +| `ANTHROPIC_CUSTOM_HEADERS` | Header list string | Extra headers; format `header-a: value, header-b: value` or newline-separated. Also forwarded outside Foundry whenever `ANTHROPIC_BASE_URL` is non-Anthropic. | | `NODE_EXTRA_CA_CERTS` | PEM path or inline PEM | Extra CA chain for server certificate validation | | `CLAUDE_CODE_CLIENT_CERT` | PEM path or inline PEM | mTLS client certificate | | `CLAUDE_CODE_CLIENT_KEY` | PEM path or inline PEM | mTLS client private key (must be paired with cert) | @@ -242,22 +246,28 @@ SearXNG also reads the equivalent `searxng.endpoint`, `searxng.token`, `searxng. ### Anthropic web search auth chain -Anthropic web search uses `findAnthropicAuth()` from `packages/ai/src/utils/anthropic-auth.ts` in this order: +`searchAnthropic()` resolves credentials in this order: -1. `ANTHROPIC_SEARCH_API_KEY` (+ optional `ANTHROPIC_SEARCH_BASE_URL`) -2. `ANTHROPIC_FOUNDRY_API_KEY` when `CLAUDE_CODE_USE_FOUNDRY` is enabled -3. Anthropic OAuth credentials from `agent.db` (must not expire within 5-minute buffer) -4. Anthropic API-key credentials from `agent.db` -5. Generic Anthropic env fallback: provider key (`ANTHROPIC_FOUNDRY_API_KEY` in Foundry mode, otherwise `ANTHROPIC_OAUTH_TOKEN`/`ANTHROPIC_API_KEY`) + optional `ANTHROPIC_BASE_URL` (`FOUNDRY_BASE_URL` when Foundry mode is enabled) +1. `ANTHROPIC_SEARCH_API_KEY` +2. `authStorage.getApiKey("anthropic")` fallback credentials (runtime/config overrides, stored API-key credentials, stored OAuth credentials, then generic Anthropic env fallback: `ANTHROPIC_FOUNDRY_API_KEY` in Foundry mode, otherwise `ANTHROPIC_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`) + +For either credential path, base URL resolution is: + +1. `ANTHROPIC_SEARCH_BASE_URL` +2. `FOUNDRY_BASE_URL` when `CLAUDE_CODE_USE_FOUNDRY` is enabled +3. `ANTHROPIC_BASE_URL` +4. `https://api.anthropic.com` Related vars: -| Variable | Default / behavior | -| --------------------------- | ---------------------------------------------------- | -| `ANTHROPIC_SEARCH_API_KEY` | Highest-priority explicit search key | -| `ANTHROPIC_SEARCH_BASE_URL` | Defaults to `https://api.anthropic.com` when omitted | -| `ANTHROPIC_SEARCH_MODEL` | Defaults to `claude-haiku-4-5` | -| `ANTHROPIC_BASE_URL` | Generic fallback base URL for tier-4 auth path | +| Variable | Default / behavior | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ANTHROPIC_SEARCH_API_KEY` | API key used exclusively for the Anthropic web search provider. Highest-priority search auth; overrides `ANTHROPIC_API_KEY` / OAuth / Foundry for search calls without affecting chat completions. | +| `ANTHROPIC_SEARCH_BASE_URL` | Base URL used exclusively for the Anthropic web search provider. Applied to either `ANTHROPIC_SEARCH_API_KEY` or fallback Anthropic credentials; overrides `ANTHROPIC_BASE_URL` (and `FOUNDRY_BASE_URL` in Foundry mode) for search calls. | +| `ANTHROPIC_SEARCH_MODEL` | Search model override. Defaults to `claude-haiku-4-5`. | +| `ANTHROPIC_BASE_URL` | Generic fallback base URL for Anthropic requests when no search-specific base URL is set. | + +Use `ANTHROPIC_SEARCH_BASE_URL` (optionally with `ANTHROPIC_SEARCH_API_KEY`) to keep chat routed through an enterprise gateway (`ANTHROPIC_BASE_URL` or `CLAUDE_CODE_USE_FOUNDRY=true`) while pointing web search at a direct Anthropic endpoint, or vice versa. ### Perplexity OAuth flow behavior flag diff --git a/docs/system-prompt-customization.md b/docs/system-prompt-customization.md new file mode 100644 index 000000000..c81b06ec4 --- /dev/null +++ b/docs/system-prompt-customization.md @@ -0,0 +1,169 @@ +# System Prompt Customization + +How the coding-agent assembles the system prompt sent to the model, and what users can control via `SYSTEM.md`, `APPEND_SYSTEM.md`, and the matching CLI flags. + +Primary implementation: + +- `packages/coding-agent/src/system-prompt.ts` (`buildSystemPrompt`, `loadSystemPromptFiles`) +- `packages/coding-agent/src/main.ts` (`discoverSystemPromptFile`, `discoverAppendSystemPromptFile`) +- `packages/coding-agent/src/prompts/system/system-prompt.md` (default stable instruction template) +- `packages/coding-agent/src/prompts/system/custom-system-prompt.md` (internal custom-prompt template; not the normal CLI `SYSTEM.md` path) +- `packages/coding-agent/src/prompts/system/project-prompt.md` (project/environment footer) + +--- + +## 1) Inputs + +Four user-controllable inputs feed prompt assembly. All four resolve a value as either a literal string or, if the argument looks like a file path, the contents of that file (`resolvePromptInput`). + +| Input | Source | Effect | +|---|---|---| +| `--system-prompt ` | CLI flag | Replaces block 0: the default stable instructions. Highest precedence. | +| `SYSTEM.md` | `/.omp/SYSTEM.md`, then `~/.omp/agent/SYSTEM.md` (and equivalent paths under `.claude`, `.codex`, `.gemini`) | Same effect as `--system-prompt`; used when the flag is absent. | +| `--append-system-prompt ` | CLI flag | Adds a prompt block. Without a custom system prompt it goes after all default blocks; with one it goes after the custom block and before the preserved project/environment footer. | +| `APPEND_SYSTEM.md` | Same discovery as `SYSTEM.md` | Same effect as `--append-system-prompt`; used when the flag is absent. | + +Discovery for `SYSTEM.md` / `APPEND_SYSTEM.md` uses `findConfigFile` (`packages/coding-agent/src/config.ts`): the first existing file across the ordered bases (`.omp`, `.claude`, `.codex`, `.gemini` — project-level at `` first, then user-level at `~`) wins. **No ancestor walk-up.** Running `omp` from `/subdir` does not pick up `/.omp/SYSTEM.md`; the file must live directly under the cwd's config base or in the user-level location. See [`docs/config-usage.md`](./config-usage.md) for the full discovery contract. + +Precedence (highest first): + +1. `--system-prompt` +2. project `SYSTEM.md` +3. user `SYSTEM.md` + +For append, the same precedence applies between `--append-system-prompt`, project `APPEND_SYSTEM.md`, and user `APPEND_SYSTEM.md`. + +--- + +## 2) Replace vs. append + +Normal CLI startup builds the default provider-facing prompt blocks first, then applies CLI / discovered file overrides in `packages/coding-agent/src/main.ts`: + +```ts +if (resolvedSystemPrompt && resolvedAppendPrompt) { + options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)]; +} else if (resolvedSystemPrompt) { + options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)]; +} else if (resolvedAppendPrompt) { + options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt]; +} +``` + +The default blocks come from `buildSystemPrompt`: + +- block 0: `system-prompt.md` — the stable default instructions (staff-engineer preamble, tool inventory, exploration rules, workflow rules, etc.); +- block 1, when non-empty: `project-prompt.md` — dynamic project/environment context (workstation info, context files, dir-context list, workspace tree, current date/cwd, and other project footer content). + +Consequences for normal CLI use: + +- Providing `--system-prompt` or `SYSTEM.md` replaces only block 0. The stable default instructions are removed, but the dynamic project/environment footer from `project-prompt.md` remains as `defaultPrompt.slice(1)`. +- Providing `--append-system-prompt` or `APPEND_SYSTEM.md` without a custom system prompt appends a new block after all default blocks. +- Providing both a custom system prompt and an append prompt produces: custom system prompt block, append prompt block, then the preserved dynamic project/environment footer. + +If you want to keep both default blocks and add to them, use `--append-system-prompt` / `APPEND_SYSTEM.md` without `--system-prompt` / `SYSTEM.md`. If you want to replace the stable default instructions while keeping the dynamic footer, use `--system-prompt` / `SYSTEM.md`. + +--- + +## 3) Templating contract + +**Contents of `SYSTEM.md`, `APPEND_SYSTEM.md`, `--system-prompt`, and `--append-system-prompt` are treated as plain text.** They are resolved before prompt-block replacement and are not rendered as Handlebars templates. + +The built-in prompt templates are Handlebars (`packages/utils/src/prompt.ts`), but user-provided strings are not compiled with that renderer. The secondary capability path can insert `systemPromptCustomization` into a Handlebars parent template, but a `{{value}}` reference in Handlebars still does not recursively render its substituted contents — the value is emitted as a string. Concretely: +```handlebars +{{! parent template — handled by Handlebars }} +{{#if systemPromptCustomization}} +{{systemPromptCustomization}} +{{/if}} +``` + +If `SYSTEM.md` contains: + +```handlebars +Working in {{cwd}} on {{date}}. +{{#if hasMemoryRoot}}Memory enabled.{{/if}} +``` + +the rendered output contains those characters verbatim — `{{cwd}}`, `{{#if hasMemoryRoot}}`, etc. are NOT substituted. They will be shown to the model as literal Handlebars syntax. + +This is by design. The internal template variables (`cwd`, `date`, `environment`, `workspaceTree`, `skills`, `rules`, `toolRefs`, `hasMemoryRoot`, `hasObsidian`, `mcpDiscoveryServerSummaries`, ...) are not a supported public surface — they change between releases as the prompt is rewritten, and they would couple user configs to internals. Treat them as private. + +If a future release exposes a templating surface for `SYSTEM.md`, it will be opt-in (e.g. via a settings flag or a different filename) and documented here. + +--- + +## 4) Recommended patterns + +### "Tweak the default" — keep default, add a few rules + +Use `APPEND_SYSTEM.md` (or `--append-system-prompt`) without `SYSTEM.md`. The default stable instructions and the dynamic project/environment footer stay intact; your text is appended as an additional block. + +```text +# ~/.omp/agent/APPEND_SYSTEM.md +Prefer Bun APIs over Node APIs in this project. +When you change a public function, run `bun check` before yielding. +``` + +### "Replace the stable default instructions" — bring your own base prompt + +Use `SYSTEM.md` (or `--system-prompt`). You replace the stable default instructions in block 0, but normal CLI startup still preserves the dynamic project/environment footer block (`project-prompt.md`): workstation info, context files, dir-context list, workspace tree, current date, cwd, and related project context. + +```text +# ~/.omp/agent/SYSTEM.md +You are a code reviewer. Read diffs, surface issues, never edit files. +- Cite paths with backticks. +- Prefer concrete fixes over abstract advice. +``` + +If you do this and want default tool guidance, exploration rules, or workflow rules, copy what you need from `packages/coding-agent/src/prompts/system/system-prompt.md` and maintain it yourself — there is currently no way to inherit selected sections from that stable default instruction block. + +### "Customize while keeping generated skills/rules/tool guidance" + +Use `APPEND_SYSTEM.md`, not `SYSTEM.md`. Skills, rulebook summaries, always-apply rules, the tool inventory, and the built-in guidance that tells the model when to read `skill://` are part of block 0 (`system-prompt.md`). Because `SYSTEM.md` replaces block 0, those generated lists are not available to the model in a custom system prompt. + +The dynamic project/environment footer that remains after `SYSTEM.md` is only block 1 (`project-prompt.md`): workstation info, AGENTS.md context files, dir-context list, workspace tree, current date, cwd, and related project context. It does not include discovered skills. + +There is currently no supported CLI mode for "replace the stable default instructions but keep the generated skills/rules/tool guidance." If you need automatic skills loading, keep the default block and add your customization via `APPEND_SYSTEM.md`. If you fully replace with `SYSTEM.md`, you must hard-code any skill names/instructions you want the model to know about, and those will not track discovery automatically. + +### "Replace everything, including project context" — SDK-only + +The normal CLI file/flag path intentionally preserves `defaultPrompt.slice(1)`. Code using `CreateAgentSessionOptions.systemPrompt` directly can return a full replacement array and omit the project footer, but that is not what `.omp/SYSTEM.md`, `~/.omp/agent/SYSTEM.md`, or `--system-prompt` do. + +### "Replace, but keep one section of the default instructions" — not directly supported + +There is no built-in way to inherit specific sections from `system-prompt.md` while replacing the rest. The supported CLI modes are: append to the default prompt, or replace block 0 and keep the dynamic footer. + +--- + +## 5) Deduplication + +The CLI path avoids double-injecting discovered `SYSTEM.md` by replacing block 0 after the default prompt blocks are rendered. Any `systemPromptCustomization` from the secondary capability path would have been rendered into block 0, and that block is discarded when `main.ts` applies `[resolvedSystemPrompt, ...defaultPrompt.slice(1)]`. + +Inside `buildSystemPrompt` itself, secondary customization and always-apply rules are still deduplicated: + +- `dedupePromptSource` drops a `systemPromptCustomization` block when it already appears in an internally supplied `customPrompt` or append prompt. +- `dedupeAlwaysApplyRules` omits always-apply rules whose body appears verbatim in any of `{customPrompt, appendPrompt, systemPromptCustomization}`. + +--- + +## 6) Discovery paths + +Only one path actually drives the customization a CLI user sees: the primary CLI path. The capability layer exists but its `SYSTEM.md` output never reaches the rendered prompt under normal CLI startup. + +- The primary CLI path (`discoverSystemPromptFile` / `discoverAppendSystemPromptFile` in `main.ts`, which feeds `resolvedSystemPrompt` / `resolvedAppendPrompt`) calls `findConfigFile`. `findConfigFile` checks only `/.omp`, `/.claude`, `/.codex`, `/.gemini`, and the user-level equivalents — it does **not** walk up ancestors. Files in `/.omp/SYSTEM.md` are ignored when `omp` is started from a subdirectory. +- The secondary capability path (`loadSystemPromptFiles` → builtin discovery) does walk up via `findNearestProjectConfigDir` and requires the project `.omp/` directory to be non-empty. Its result is rendered into the template variable `systemPromptCustomization`. Under normal CLI startup the default template (`system-prompt.md`) never references that variable, so ancestor-walk capability content has no user-visible effect. + +Net effect for CLI users: put `SYSTEM.md` / `APPEND_SYSTEM.md` directly under `/.omp` (or another supported config base under cwd) or in the user-level location (`~/.omp/agent/SYSTEM.md` etc.). Ancestor paths are not searched. + +--- + +## 7) Quick reference + +| Goal | Use | +|---|---| +| Add an instruction on top of the full default prompt | `APPEND_SYSTEM.md` or `--append-system-prompt` | +| Replace the stable default instructions but keep project/environment context | `SYSTEM.md` or `--system-prompt` | +| Preserve generated skills/rules/tool guidance while customizing | `APPEND_SYSTEM.md`; `SYSTEM.md` replaces that generated block | +| Use `{{cwd}}` / `{{date}}` / other internals in my file | Not supported. Files are inserted verbatim. | +| Inherit specific sections from `system-prompt.md` | Not supported; use append, or copy what you need into `SYSTEM.md`. | +| Override at a per-repo level | Project `.omp/SYSTEM.md` under the cwd you launch `omp` from | +| Override globally | `~/.omp/agent/SYSTEM.md` or `~/.omp/agent/APPEND_SYSTEM.md` | diff --git a/docs/tools/eval.md b/docs/tools/eval.md index 442c6e46c..835730249 100644 --- a/docs/tools/eval.md +++ b/docs/tools/eval.md @@ -132,15 +132,15 @@ Implemented in `packages/coding-agent/src/eval/js/worker-core.ts`, `packages/cod - `read`, `write`, `append`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output` - `tool.(args)` proxy for arbitrary session tool calls - `llm(prompt, opts?)` for oneshot, stateless LLM calls (see _Oneshot LLM helper_ below) - - `agent(prompt, opts?)` for a single subagent call, plus JS-only `parallel()` / `pipeline()` bounded-pool helpers (see _Subagent helper_ below) + - `agent(prompt, opts?)` for a single subagent call, plus `parallel()` / `pipeline()` bounded-pool helpers (see _Subagent helper_ below) - JS helpers that touch the host/runtime boundary are async and `await`able; pure text helpers (`sort`, `uniq`, `counter`) return synchronously but may still be safely awaited. - JS helper signatures use a trailing options object rather than Python keyword arguments: - `await read(path, { offset?, limit? })` - `await tree(path = ".", { maxDepth?, hidden? })` - `sort(text, { reverse?, unique? })`, `uniq(text, { count? })`, `counter(items, { limit?, reverse? })` - `await agent(prompt, { agentType?, model?, context?, label?, schema? })` - - `await parallel([() => agent("a"), () => agent("b")], { concurrency? })` - - `await pipeline(items, stage1, stage2, { concurrency? })` + - `await parallel([() => agent("a"), () => agent("b")])` + - `await pipeline(items, stage1, stage2)` - `display(value)` behavior: - plain objects/arrays become JSON outputs - `{ type: "image", data, mimeType }` becomes an image output @@ -199,7 +199,7 @@ Both runtimes expose `agent()` — a single subagent invocation routed through ` - `context` supplies shared background; `label` controls the `agent://` output label prefix. - `schema` passes a JSON Schema to the subagent structured-output path. When present, the helper parses the final JSON text and returns an object. - Spawn restrictions use `session.getSessionSpawns()` exactly like the `task` tool. Eval-driven subagent recursion is capped at depth 3. -- JS also exposes `parallel(thunks, { concurrency })` and `pipeline(items, ...stages, { concurrency })`; both use a bounded async pool with default concurrency 4, max 16, preserve item order, and propagate rejections. +- JS and Python both expose `parallel(thunks)` and `pipeline(items, ...stages)`; both use a bounded async/threaded pool whose width tracks the `task.maxConcurrency` setting (the same ceiling the `task` tool uses; `0` = run every item at once), preserve item order, and propagate rejections. The width is fetched live from the host via the `__concurrency__` bridge, so the helpers no longer take a `concurrency` argument. - Errors surface as exceptions: unknown or disabled agent, disallowed spawn, recursion cap, subagent failure, or invalid structured output all fail the eval cell. ### Multi-language call behavior @@ -245,7 +245,7 @@ A single tool call can mix Python and JS cells. Persistence is per language runt - Output truncation window: 50KB default (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`) - Output line cap inside truncation helpers: 3000 lines (`DEFAULT_MAX_LINES` in `packages/coding-agent/src/session/streaming-output.ts`) - Streaming tail buffer for live updates: `DEFAULT_MAX_BYTES * 2` = 100KB (`packages/coding-agent/src/tools/eval.ts`) -- JS `parallel()` / `pipeline()` helper concurrency default: 4; maximum: 16 +- JS/Python `parallel()` / `pipeline()` helper pool width: the `task.maxConcurrency` setting (default 32; `0` = unbounded), resolved live via the `__concurrency__` bridge (`packages/coding-agent/src/eval/concurrency-bridge.ts`) - Eval-driven `agent()` recursion cap: task depth 3 (`EVAL_AGENT_MAX_DEPTH`) - Python retained kernel idle timeout: 5 minutes (`IDLE_TIMEOUT_MS` in `packages/coding-agent/src/eval/py/executor.ts`) - Python retained kernel cap: 4 sessions (`MAX_KERNEL_SESSIONS` in `packages/coding-agent/src/eval/py/executor.ts`) diff --git a/docs/tools/find.md b/docs/tools/find.md index b54a60a69..823365047 100644 --- a/docs/tools/find.md +++ b/docs/tools/find.md @@ -18,7 +18,7 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `paths` | `string[]` | Yes | One or more globs, files, directories, or internal URLs with backing files. Empty strings and comma-joined multi-path entries such as `["a,b"]` are rejected. Multiple entries may be merged into one brace-union search when their base paths can be resolved together. | +| `paths` | `string[]` | Yes | One or more globs, files, directories, or internal URLs with backing files. Empty strings are rejected. Single entries accidentally joined with comma, semicolon, or whitespace are expanded only after existence validation; existing paths containing delimiters stay intact. Multiple entries may be merged into one brace-union search when their base paths can be resolved together. | | `hidden` | `boolean` | No | Whether hidden files are included. Defaults to `true` (`hidden ?? true`). | | `gitignore` | `boolean` | No | Whether `.gitignore` is respected during local native globbing. Defaults to `true`; set `false` to include gitignored files. | | `limit` | `number` | No | Max returned paths. Defaults to `200`; finite positive inputs are floored then clamped to `1..200`. | @@ -41,22 +41,24 @@ The tool returns a single text block plus structured `details`. - Streaming: when the runtime supplies `onUpdate`, the local implementation emits incremental newline-delimited text snapshots during globbing, throttled to 200 ms. Final output is grouped; streaming snapshots are not. ## Flow -1. `FindTool.execute()` normalizes each `paths` entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/find.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``. -2. For multi-path local calls, `partitionExistingPaths(..., parseFindPattern)` (`packages/coding-agent/src/tools/path-utils.ts`) stats each base path. Missing entries are skipped; if all are missing, the tool throws `Path not found: ...`. Single missing paths still hard-fail. -3. The tool tries `resolveExplicitFindPatterns()` to merge multiple inputs into one search rooted at a common base path. If that does not apply, it parses one input with `parseFindPattern()`. -4. `parseFindPattern()` determines `(basePath, globPattern, hasGlob)`: + +1. `FindTool.execute()` expands delimiter-flattened local `paths` entries with `expandDelimitedPathEntries(..., parseFindPattern)` unless custom operations are injected. The splitter validates candidate parts by statting their parsed base paths, keeps existing delimiter-containing paths intact, accepts comma/semicolon splits when at least one part resolves, and accepts whitespace splits only when every part resolves. +2. The tool normalizes each resulting entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/find.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``. +3. For multi-path local calls, `partitionExistingPaths(..., parseFindPattern)` (`packages/coding-agent/src/tools/path-utils.ts`) stats each base path. Missing entries are skipped; if all are missing, the tool throws `Path not found: ...`. Single missing paths still hard-fail. +4. The tool tries `resolveExplicitFindPatterns()` to merge multiple inputs into one search rooted at a common base path. If that does not apply, it parses one input with `parseFindPattern()`. +5. `parseFindPattern()` determines `(basePath, globPattern, hasGlob)`: - no glob chars (`*`, `?`, `[`, `{`) => search that path with implicit `**/*`. - glob in the first segment => search from `.` and, unless the pattern already starts with `**/`, prefix it with `**/`. - glob later in the path => split at the first glob-bearing segment. -5. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`. -6. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`. -7. Execution then branches: +6. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`. +7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`. +8. Execution then branches: - **Custom operations branch**: if `FindToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`. - **Built-in local branch**: the tool stats `searchPath`. Exact-file inputs return immediately. Directory inputs call `natives.glob()` with `hidden`, `maxResults: effectiveLimit`, `sortByMtime: true`, `gitignore: useGitignore`, and the combined abort signal. -8. In the local branch, optional `onMatch` callbacks convert each match to a cwd-relative display path and emit throttled progress updates. -9. After native glob returns, JS sorts `result.matches` by `mtime` descending (`(b.mtime ?? 0) - (a.mtime ?? 0)`) before formatting paths. -10. `buildResult()` applies `applyListLimit()` to cap the array again at `effectiveLimit`, formats paths with `formatFindGroupedOutput()`, appends notices, then runs `truncateHead()` with `maxLines: Number.MAX_SAFE_INTEGER`. In practice this leaves the 50 KB byte cap in place while disabling the default 3000-line cap. -11. `toolResult()` packages text plus `details`, and records result-limit / truncation metadata for renderers. +9. In the local branch, optional `onMatch` callbacks convert each match to a cwd-relative display path and emit throttled progress updates. +10. After native glob returns, JS sorts `result.matches` by `mtime` descending (`(b.mtime ?? 0) - (a.mtime ?? 0)`) before formatting paths. +11. `buildResult()` applies `applyListLimit()` to cap the array again at `effectiveLimit`, formats paths with `formatFindGroupedOutput()`, appends notices, then runs `truncateHead()` with `maxLines: Number.MAX_SAFE_INTEGER`. In practice this leaves the 50 KB byte cap in place while disabling the default 3000-line cap. +12. `toolResult()` packages text plus `details`, and records result-limit / truncation metadata for renderers. ## Modes / Variants - **Exact file path**: if the parsed input has no glob and the resolved path stats as a file, output is that one path. @@ -90,7 +92,6 @@ The tool returns a single text block plus structured `details`. ## Errors - User-facing `ToolError`s from `FindTool.execute()` include: - - `paths is an array — pass ["a", "b"] not ["a,b"] ...` - `` `paths` must contain non-empty globs or paths `` - `Path not found: ...` - `Searching from root directory '/' is not allowed` diff --git a/docs/tools/irc.md b/docs/tools/irc.md index 98996443b..bf2ff70a3 100644 --- a/docs/tools/irc.md +++ b/docs/tools/irc.md @@ -28,7 +28,7 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | | `op` | `"send"` | Yes | Sends one message to one peer or to `"all"`. | -| `to` | `string` | Yes | Peer id such as `0-Main`, or `"all"` for broadcast. Whitespace is trimmed. | +| `to` | `string` | Yes | Peer id such as `Main`, or `"all"` for broadcast. Whitespace is trimmed. | | `message` | `string` | Yes | Message body. Whitespace is trimmed; empty-after-trim is rejected. | | `awaitReply` | `boolean` | No | Wait for prose replies. Defaults to `true` for direct messages and `false` for `to: "all"`. | @@ -44,7 +44,7 @@ ## Flow 1. `IrcTool.createIf` only constructs the tool when `irc.enabled` is on and the session has both an `AgentRegistry` and `getAgentId` (`packages/coding-agent/src/tools/irc.ts`). -2. Tool discovery adds another gate in `packages/coding-agent/src/tools/index.ts`: if the caller is `0-Main` and `async.enabled` is off, `irc` is hidden because the main agent cannot talk to concurrent peers in sync mode. +2. Tool discovery adds another gate in `packages/coding-agent/src/tools/index.ts`: if the caller is `Main` and `async.enabled` is off, `irc` is hidden because the main agent cannot talk to concurrent peers in sync mode. 3. `execute` resolves the process-global registry and sender id. Missing either returns a text error result instead of throwing. 4. `op: "list"` calls `registry.listVisibleTo(senderId)`, which exposes every other agent in flat namespace whose status is `running` or `idle` (`packages/coding-agent/src/registry/agent-registry.ts`). 5. `list` formats human-readable lines and returns `channels` as `['all', ...peerIds]`. These are logical targets only; there is no channel join state. diff --git a/docs/tools/search.md b/docs/tools/search.md index e63894765..46afb3b53 100644 --- a/docs/tools/search.md +++ b/docs/tools/search.md @@ -21,7 +21,7 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | | `pattern` | `string` | Yes | Regex pattern. `search.ts` trims it and rejects empty input. The native matcher enables multiline only when the pattern text contains a literal newline or the two-character sequence `\\n`. The model prompt explicitly documents literal-brace escaping such as ``interface\\{\\}``, although the native layer also auto-escapes braces that cannot be valid repetition quantifiers. | -| `paths` | `string \| string[]` | Yes | One file path, directory path, glob-like path, archive member, internal URL, or an array of those. Append a line-range selector such as `:50-100` or `:5-16,960-973` to a single file/archive/internal-resource input to constrain matches. Empty strings and comma-joined multi-path entries are rejected after trimming/quote stripping. Filesystem-backed internal URLs search their backing file; virtual internal resources search resolved text in memory. Internal URLs cannot contain glob characters. | +| `paths` | `string \| string[]` | Yes | One file path, directory path, glob-like path, archive member, internal URL, or an array of those. Append a line-range selector such as `:50-100` or `:5-16,960-973` to a single file/archive/internal-resource input to constrain matches. Empty strings are rejected after trimming/quote stripping. Single entries accidentally joined with comma, semicolon, or whitespace are expanded only after existence validation; existing paths containing delimiters stay intact. Filesystem-backed internal URLs search their backing file; virtual internal resources search resolved text in memory. Internal URLs cannot contain glob characters. | | `i` | `boolean` | No | Case-insensitive search. Defaults to `false`. Passed to native `ignoreCase` or JS `RegExp` flags for virtual resources. | | `gitignore` | `boolean` | No | Respect `.gitignore` during directory scans. Defaults to `true`. Passed to native `gitignore`. | | `skip` | `number` | No | File-page offset for multi-file results. Defaults to `0`; `search.ts` floors finite numbers and rejects negative or non-finite values. Single-file searches ignore it because they do not paginate by file. | @@ -48,10 +48,11 @@ The tool returns a single text block in `content[0].text` plus structured `detai 1. `SearchTool.execute()` validates and normalizes input in `packages/coding-agent/src/tools/search.ts`: - trims `pattern`, rejects empty patterns; - normalizes `skip` to a non-negative integer; + - expands delimiter-flattened `paths` entries with `expandDelimitedPathEntries()`, keeping existing delimiter-containing paths intact, accepting comma/semicolon splits when at least one part resolves, and accepting whitespace splits only when every part resolves; + - peels any line-range selector from each resulting entry; - reads `search.contextBefore` and `search.contextAfter` from session settings (`1` and `3` by default); - - enables multiline only when `pattern` contains `\n` or an actual newline; - - wraps a single string `paths` value into a one-element list and peels any line-range selector from each entry. -2. Each `paths` entry is normalized with `normalizePathLikeInput()`. + - enables multiline only when `pattern` contains `\n` or an actual newline. +2. Each `paths` entry is normalized with `normalizePathLikeInput()` again during shared scope resolution; this is a no-op for entries already normalized by delimiter expansion. 3. Archive member paths such as `bundle.zip:src/foo.ts` are materialized to temporary UTF-8 scratch files before native grep. Binary or non-UTF-8 archive members are reported as skipped/unreadable. 4. Internal URLs are resolved before filesystem scope resolution: - glob metacharacters (`*`, `?`, `[`, `{`) are rejected for internal URLs; @@ -139,7 +140,6 @@ The tool returns a single text block in `content[0].text` plus structured `detai - `Pattern must not be empty` when trimmed `pattern` is empty. - `Skip must be a non-negative number` for negative or non-finite `skip`. - `` `paths` must contain non-empty paths or globs `` when any normalized path is empty. -- `paths is an array — pass ["a", "b"] not ["a,b"] ...` for comma-joined multi-path entries outside brace expansion. - `Glob patterns are not supported for internal URLs: ...` for internal URL + glob metacharacters. - Line-range selector errors include `Line-range selector requires a single file, not a glob: ...`, `Line-range selector requires a single file: ... is a directory`, and `Path not found for line-range selector: ...`. - `Cannot search archive member(s): ...` when all archive selectors are unreadable, binary, or non-UTF-8. diff --git a/docs/tools/task.md b/docs/tools/task.md index 245824836..98026680c 100644 --- a/docs/tools/task.md +++ b/docs/tools/task.md @@ -222,4 +222,4 @@ Artifacts and side channels: - Branch-mode merge temporarily stashes the parent repo before cherry-picking task branches. A stash-pop conflict is treated as merge failure and leaves recovery state behind. - Patch-mode only applies combined root patches if every successful task produced a patch and `git.patch.canApplyText(...)` succeeds. - Nested git repos are handled separately from the root repo. They are copied into isolated worktrees, diffed independently, and merged later with `applyNestedPatches(...)` because parent git cannot track their file-level changes. -- `agent://` ids are numeric-prefixed (`0-Task`, `1-Task`, nested like `0-Parent.0-Child`) by `AgentOutputManager`; this is what prevents artifact collisions across repeated or nested task invocations. +- `agent://` ids are name-based (`Task` first, `Task-2`/`Task-3` only when the name repeats, nested like `Parent.Child`) by `AgentOutputManager`; this is what prevents artifact collisions across repeated or nested task invocations. diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index f47a1b968..35266124a 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -121,7 +121,11 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `limit` / `num_search_results`: `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`. - Output: `sources`, `requestId`. - **Anthropic** — `packages/coding-agent/src/web/search/providers/anthropic.ts` - - Availability: `findAnthropicAuth()` from `@oh-my-pi/pi-ai`. + - Availability: `ANTHROPIC_SEARCH_API_KEY` env var, otherwise `authStorage.hasAuth("anthropic")`; search credentials come from `authStorage.getApiKey("anthropic")` when no search-specific key is set. + - Env overrides specific to search (do not affect chat completions): + - `ANTHROPIC_SEARCH_API_KEY` — highest-priority search auth; overrides `ANTHROPIC_API_KEY` / OAuth / `ANTHROPIC_FOUNDRY_API_KEY` for the search call only. + - `ANTHROPIC_SEARCH_BASE_URL` — search-only base URL for either `ANTHROPIC_SEARCH_API_KEY` or fallback Anthropic credentials; overrides `ANTHROPIC_BASE_URL` (and `FOUNDRY_BASE_URL` in Foundry mode); defaults to `https://api.anthropic.com`. + - `ANTHROPIC_SEARCH_MODEL` — search model; defaults to `claude-haiku-4-5`. - Querying: Claude Messages API with web-search tool enabled. - `max_tokens` and `temperature` pass through. - `limit` and `num_search_results` are collapsed together before dispatch: `num_results = params.numSearchResults ?? params.limit`. @@ -145,8 +149,8 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `limit` and `num_search_results` are collapsed together before dispatch. - Output may include parsed free-text `answer`, `sources`, `requestId`. - **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts` - - Availability: always true unless settings explicitly disable `exa.enabled` or `exa.enableSearch`; the adapter can use public MCP even without `EXA_API_KEY`. - - Querying: with `EXA_API_KEY`, POST `https://api.exa.ai/search`; otherwise call MCP tool `web_search_exa`. + - Availability: env or `agent.db` credential for `exa`; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. + - Querying: POST `https://api.exa.ai/search` with the resolved Exa API key. - `limit` and `num_search_results` are collapsed together before dispatch. - Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`. - **Parallel** — `packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts` diff --git a/package.json b/package.json index 46f69976d..397502658 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ ], "catalog": { "@agentclientprotocol/sdk": "0.22.1", - "@anthropic-ai/sdk": "^0.99.0", "@babel/generator": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", @@ -21,15 +20,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.7.6", - "@oh-my-pi/omp-stats": "15.7.6", - "@oh-my-pi/pi-agent-core": "15.7.6", - "@oh-my-pi/pi-ai": "15.7.6", - "@oh-my-pi/pi-coding-agent": "15.7.6", - "@oh-my-pi/pi-mnemopi": "15.7.6", - "@oh-my-pi/pi-natives": "15.7.6", - "@oh-my-pi/pi-tui": "15.7.6", - "@oh-my-pi/pi-utils": "15.7.6", + "@oh-my-pi/hashline": "15.8.0", + "@oh-my-pi/omp-stats": "15.8.0", + "@oh-my-pi/pi-agent-core": "15.8.0", + "@oh-my-pi/pi-ai": "15.8.0", + "@oh-my-pi/pi-coding-agent": "15.8.0", + "@oh-my-pi/pi-mnemopi": "15.8.0", + "@oh-my-pi/pi-natives": "15.8.0", + "@oh-my-pi/pi-tui": "15.8.0", + "@oh-my-pi/pi-utils": "15.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", @@ -85,6 +84,7 @@ "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", "dev": "bun --cwd=packages/coding-agent src/cli.ts", "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", + "claude:trace": "bun scripts/claude-trace.ts", "build": "bun run --workspaces --if-present build", "build:native": "bun --cwd=packages/natives run build", "test": "bun run --parallel test:ts test:rs", @@ -108,6 +108,7 @@ "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", + "fix:changelogs": "bun scripts/fix-changelogs.ts", "fix:rs": "bun scripts/run-rs-task.ts fix:rs", "ci:check:full": "bun run check:ts", "ci:build:native": "bun scripts/ci-build-native.ts", @@ -123,7 +124,7 @@ "stats:tools": "python3 scripts/session-stats/analyze.py tools", "stats:edits": "python3 scripts/session-stats/analyze.py edits", "stats:followups": "python3 scripts/session-stats/analyze.py followups", - "test:py": "python3 -m pytest -x python/omp-rpc/tests python/robomp/tests", + "test:py": "python3 -m pytest -x python/omp-rpc/tests && python3 -m pytest -x python/robomp/tests", "robomp:install": "pip install -e 'python/robomp[dev]'", "robomp:serve": "python3 -m robomp serve", "robomp:test:integration": "ROBOMP_INTEGRATION=1 python3 -m pytest -x python/robomp/tests/test_worker_smoke.py", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 1731dd054..02beb32d8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + +### Fixed + +- Engaged GPT-5 Harmony leak detection on the committed assistant message (openai-codex only). `detectHarmonyLeakInAssistantMessage` now runs on the streamed `done`/`error` result and the trailing fallback, so a leaked final response is aborted-and-retried by the existing mitigation instead of being committed as-is. Tool-argument (`tool_arg`) scanning is gated on the trailing-garbage `T` co-signal and only fires when a caller supplies a parse boundary via `detectHarmonyLeakInAssistantMessage`'s new optional `toolArgParseEnd` resolver. The agent loop passes none — it cannot bound a streamed tool DSL — so that surface stays inert and a legitimate codex tool call whose content legitimately carries `to=functions.*` next to a channel word or non-Latin script (e.g. editing the harmony fixtures) is never hard-aborted. + ## [15.7.4] - 2026-05-31 ### Removed @@ -19,6 +25,7 @@ - Fixed tool-output pruning and shake protection for `read`: ordinary file/URL reads are now eligible for compaction, while `read` calls whose `path` starts with `skill://` remain protected like native `skill` results. ## [15.5.15] - 2026-05-30 + ### Added - Added `maxToolCallsPerTurn` to `AgentLoopConfig`/`AgentOptions`, allowing callers to cut a streamed assistant turn after a completed tool-call batch and execute the runnable partial turn instead of waiting for the provider to yield. @@ -44,6 +51,7 @@ - Changed `Agent.appendMessage`, `popMessage`, `clearMessages`, and `reset` to mutate `state.messages` and `state.pendingToolCalls` in place instead of allocating a fresh array/Set on every transition. Subscribers that capture `state.messages` by reference now observe updates without needing to re-read `state` after each event. The public type signature is unchanged (always `AgentMessage[]` / `Set`). ## [15.5.0] - 2026-05-26 + ### Added - Added `approval` support to `AgentTool` declarations with the new `ToolTier` and `ToolApproval` APIs, allowing tools to declare capability tiers (`read`, `write`, or `exec`) and optional override/reason metadata for approval gating @@ -55,23 +63,27 @@ - Fixed chat-request telemetry storing the raw scoped `serviceTier` value (`"openai-only"`/`"claude-only"`) in `OpenAIAttr.RequestServiceTier` instead of the resolved wire value (`"priority"`). Dashboards and alerts filtering on the concrete tier name (`service_tier == "priority"`) were broken by the scoped placeholder; `buildChatRequestAttributes` now runs the tier through `resolveServiceTier(serviceTier, provider)` before recording, keeping the `shouldSendServiceTier` gate intact so non-OpenAI providers continue to omit the attribute entirely. ## [15.3.0] - 2026-05-25 + ### Fixed - Fixed `transformContext` receiving the loop config object as the `signal` argument instead of the actual `AbortSignal`, so hooks that check `signal.aborted` or call `signal.addEventListener` now work correctly under abort/timeout conditions - Fixed `appendOnlyContext` not being re-evaluated after `setModel()` — the mode was decided once at session construction based on the initial model's provider, so switching from/to DeepSeek (or changing `provider.appendOnlyContext`) mid-session produced incorrect mode behavior ## [15.2.3] - 2026-05-22 + ### Added - Added `onBeforeYield` hook support so user code can run right before the agent loop checks for follow-up messages ## [15.1.3] - 2026-05-17 + ### Added - Added optional `telemetry` support to `generateSummary`, `generateHandoff`, `generateBranchSummary`, and `compact` options so compaction, handoff, and branch summary one-shot LLM calls can emit OpenTelemetry chat telemetry when enabled - Added shared oneshot telemetry instrumentation for compaction, handoff, and branch summary calls, tagging spans with `pi.gen_ai.oneshot.kind` values such as `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`, `handoff`, and `branch_summary` ## [15.1.2] - 2026-05-15 + ### Added - Added `responseHeaders` to `ChatUsageEvent` and `ManualChatTelemetryOptions` so telemetry hooks receive captured lowercase upstream response headers for each chat span @@ -79,6 +91,7 @@ - Added exported `detectGatewayFromHeaders` API for header-based gateway detection ## [15.1.0] - 2026-05-15 + ### Breaking Changes - Removed the `@oh-my-pi/pi-agent-core/compaction/handoff` exports from the package surface, including `extractHandoffDocument`, `createHandoffContext`, and `createHandoffFileName` @@ -129,6 +142,7 @@ - Fixed unbounded recursion in summary content capture when a captured value contains a cyclic or deeply nested array — array recursion now respects the same depth cap as plain-object recursion and replaces back-references with `"[Circular]"` ## [15.0.1] - 2026-05-14 + ### Breaking Changes - Raised the minimum required Bun version from >=1.3.7 to >=1.3.14 @@ -140,6 +154,7 @@ - Added an `isError?: boolean` field on `AgentToolResult` so tools can flag a non-throwing failure (e.g. an aggregator that catches per-entry errors). `coerceToolResult` preserves the flag and the agent loop surfaces it as a tool error on the wire. ## [14.9.3] - 2026-05-10 + ### Added - Added `onHarmonyLeak` option on `Agent`/loop config to receive GPT-5 Harmony leak audit callbacks @@ -155,13 +170,11 @@ - Hardened failure handling so repeated GPT-5 Harmony leak mitigation is retried only up to two times before escalating to an explicit error ## [14.9.0] - 2026-05-10 + ### Added - Added `Agent#metadata` field forwarded to every API request; callers can set arbitrary provider metadata (e.g. `metadata.user_id`) once and have it applied to all subsequent stream calls without modifying per-call options - Added `Agent#setMetadataResolver(fn)` for installing a function that resolves request metadata at call time. The `metadata` getter dispatches through the resolver on every read (including the snapshot taken per `prompt()`), so callers reflect mutable external state (e.g. live OAuth account UUID after a token refresh) without manual re-syncs. Plain `agent.metadata = …` continues to set a static value and clears any installed resolver. - -### Added - - Added an `onSseEvent` agent option and loop config forwarding path for raw provider SSE diagnostics. ## [14.7.6] - 2026-05-07 @@ -169,13 +182,16 @@ ### Added - Added `hideThinkingSummary` option/getter/setter on `Agent` and `AgentLoopConfig`. Forwarded to the underlying stream call so providers can omit reasoning/thinking summaries on demand. + ## [14.7.2] - 2026-05-06 + ### Added - Added `loadMode` option to `AgentTool` to mark built-in tools as `essential` for initial loading or `discoverable` for search activation - Added optional `summary` field to `AgentTool` definitions for one-line text used in tool discovery indexes ## [14.7.0] - 2026-05-04 + ### Breaking Changes - Changed `Agent` API types so `systemPrompt` is now a list of prompt strings, requiring callers to pass and update system prompts via string arrays @@ -195,6 +211,7 @@ - Fixed unhandled promise rejection when `getApiKey` or any other async error occurs during `streamAssistantResponse`: agent loop IIFEs now catch and route errors through `EventStream.fail()`, which terminates the `for await` loop and lets `Agent#runLoop`'s catch block create a proper error assistant message instead of crashing ## [14.6.0] - 2026-05-02 + ### Fixed - Fixed request cancellation before provider events by emitting an aborted assistant message and ending the stream with `stopReason: "aborted"` @@ -212,6 +229,7 @@ - Changed tool dispatch to match model-returned tool calls by either internal tool name or custom wire name, enabling custom OpenAI tool names such as `apply_patch`. ## [14.0.1] - 2026-04-08 + ### Added - Added `onAssistantMessageEvent` callback option to inspect assistant streaming events before they are emitted, enabling abort decisions before buffered events continue flowing @@ -234,6 +252,7 @@ - Fixed stale forced toolChoice being passed to provider after tools are refreshed mid-turn ## [13.9.16] - 2026-03-10 + ### Added - Added `onPayload` option to `AgentOptions` to inspect or replace provider payloads before they are sent @@ -252,11 +271,13 @@ - Updated `setThinkingLevel()` method to accept `Effort | undefined` instead of `ThinkingLevel` string ## [13.4.0] - 2026-03-01 + ### Added - Added `getToolChoice` option to dynamically override tool choice per LLM call ## [13.3.8] - 2026-02-28 + ### Changed - Changed intent field name from `agent__intent` to `_i` in tool schemas @@ -264,12 +285,15 @@ ### Fixed - Fixed synthetic tool result text formatting so aborted/error tool results no longer emit `Tool execution was aborted.: Request was aborted` style punctuation. + ## [13.3.7] - 2026-02-27 + ### Added - Added `lenientArgValidation` option to tools to allow graceful handling of argument validation errors by passing raw arguments to execute() instead of returning an error to the LLM ## [13.3.1] - 2026-02-26 + ### Added - Added `topP`, `topK`, `minP`, `presencePenalty`, and `repetitionPenalty` options to `AgentOptions` for fine-grained sampling control @@ -280,7 +304,9 @@ ### Changed - Removed per-tool `agent__intent` field description from injected schema to reduce token usage; intent format is now documented once in the system prompt instead of repeated in every tool definition + ## [12.19.0] - 2026-02-22 + ### Changed - Updated tool result messages to include error details when tool execution fails @@ -525,4 +551,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon - `Agent` constructor now has all options optional (empty options use defaults). -- `queueMessage()` is now synchronous (no longer returns a Promise). \ No newline at end of file +- `queueMessage()` is now synchronous (no longer returns a Promise). diff --git a/packages/agent/package.json b/packages/agent/package.json index cfa97c22c..841dc7921 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "15.7.6", + "version": "15.8.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index ef3a4d396..125b67fc7 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -17,6 +17,8 @@ import { import { sanitizeText } from "@oh-my-pi/pi-utils"; import { createHarmonyAuditEvent, + detectHarmonyLeakInAssistantMessage, + extractHarmonyRemoved, type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, @@ -924,6 +926,17 @@ async function streamAssistantResponse( case "done": case "error": { const finalMessage = await response.result(); + if (harmonyMitigationEnabled) { + const detection = detectHarmonyLeakInAssistantMessage(finalMessage); + if (detection) { + const removed = extractHarmonyRemoved(finalMessage, detection); + if (addedPartial) { + context.messages.pop(); + addedPartial = false; + } + throw new HarmonyLeakInterruption(detection, removed); + } + } if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; } else { @@ -943,6 +956,16 @@ async function streamAssistantResponse( } const trailing = await response.result(); + if (harmonyMitigationEnabled) { + const detection = detectHarmonyLeakInAssistantMessage(trailing); + if (detection) { + if (addedPartial) { + context.messages.pop(); + addedPartial = false; + } + throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection)); + } + } await finishChat(trailing); return trailing; }); diff --git a/packages/agent/src/harmony-leak.ts b/packages/agent/src/harmony-leak.ts index 6d59e5f26..3ea34b2e4 100644 --- a/packages/agent/src/harmony-leak.ts +++ b/packages/agent/src/harmony-leak.ts @@ -129,8 +129,18 @@ export function signalListLabel(signals: readonly HarmonySignal[]): string { * (`C`/`G`/`S`/`B`/`R`/`T`). Bare `M` does not trip — this document, its * tests, and bug reports legitimately carry the marker. * + * The `tool_arg` surface is held to a stricter rule. A tool argument is + * arbitrary file/data content that can legitimately carry the marker, a + * channel word, harmony control tokens, or a non-Latin script run (editing + * these very fixtures does exactly that). The only robust leak signal there + * is content trailing the structurally-valid parse, so a `tool_arg` detection + * additionally requires the `T` co-signal. Absent a `parsedEnd` boundary `T` + * is never set, so `tool_arg` scanning stays inert and a legitimate codex tool + * call is never hard-aborted. `assistant_text`/`assistant_thinking` keep the + * base rule. + * * `parsedEnd`, when supplied, marks the byte at which a structurally valid - * tool-argument parse ends; markers strictly after it set the `T` co-signal. + * tool-argument parse ends; markers at or past it set the `T` co-signal. * `contentIndex`/`toolName`/`toolCallId` flow through to the returned * detection for downstream auditing. */ @@ -177,6 +187,12 @@ export function detectHarmonyLeak( } if (signals.length === 0) return undefined; + // Tool arguments are data: they can legitimately embed the marker, a channel + // word, harmony control tokens, or a non-Latin script run. Only a marker + // trailing the structurally-valid parse (`T`) is a reliable leak signal, so + // refuse to trip a `tool_arg` detection without it. Without a `parsedEnd` + // boundary `T` is never set and the surface stays inert. + if (surface === "tool_arg" && !signals.some(s => s.classes.includes("T"))) return undefined; signals.sort((a, b) => a.start - b.start || a.end - b.end); return { surface, @@ -187,8 +203,20 @@ export function detectHarmonyLeak( }; } -/** Scan an assistant message's content blocks; return the first detection. */ -export function detectHarmonyLeakInAssistantMessage(message: AssistantMessage): HarmonyDetection | undefined { +/** + * Scan an assistant message's content blocks; return the first detection. + * + * `toolArgParseEnd`, when supplied, resolves the byte offset at which a tool + * call's structurally-valid argument parse ends (the `T` co-signal in + * {@link detectHarmonyLeak}). Callers that can parse a tool's argument DSL pass + * it to enable `tool_arg` leak detection; omitting it keeps that surface inert + * — the safe default the agent loop relies on, since it cannot bound a streamed + * tool DSL and must never hard-abort a legitimate tool call. + */ +export function detectHarmonyLeakInAssistantMessage( + message: AssistantMessage, + toolArgParseEnd?: (toolCall: ToolCall) => number | undefined, +): HarmonyDetection | undefined { for (let i = 0; i < message.content.length; i++) { const block = message.content[i]; if (block.type === "text") { @@ -204,6 +232,7 @@ export function detectHarmonyLeakInAssistantMessage(message: AssistantMessage): contentIndex: i, toolName: block.name, toolCallId: block.id, + parsedEnd: toolArgParseEnd?.(block), }); if (d) return d; } diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 815a748ad..ff085f383 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -56,6 +56,76 @@ describe("agentLoop with AgentMessage", () => { expect(eventTypes).toContain("agent_end"); }); + it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + // First response leaks a harmony payload as visible assistant text; the + // retry is clean. Mitigation only engages for openai-codex. + const leak = "Some prose. analysis to=functions.edit code 大发官网"; + const mock = createMockModel({ + provider: "openai-codex", + responses: [{ content: [leak] }, { content: ["clean retry response"] }], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream); + for await (const event of stream) { + events.push(event); + } + const messages = await stream.result(); + + // The leaked attempt was retried, not committed. + expect(mock.calls).toHaveLength(2); + expect(messages).toHaveLength(2); + const final = messages[1]; + if (final.role !== "assistant") throw new Error("expected assistant message"); + expect(final.content).toEqual([{ type: "text", text: "clean retry response" }]); + expect(JSON.stringify(messages)).not.toContain("to=functions."); + }); + + it("does not hard-abort a codex tool call whose argument legitimately carries the marker", async () => { + // A legit edit of a file (e.g. these harmony fixtures) whose content carries + // `to=functions.*` next to a channel word + non-Latin script. tool_arg is + // gated on the trailing-garbage `T` co-signal, and the loop supplies no parse + // boundary, so the call commits + executes once instead of being detected as + // a leak and retried/escalated. + const toolSchema = z.object({ input: z.string() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "edit", + label: "Edit", + description: "Edit tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.input); + return { content: [{ type: "text", text: "ok" }], details: { input: params.input } }; + }, + }; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const leakyArg = "@fixtures/corpus.json\n+\tanalysis to=functions.edit code 大发官网\n"; + const mock = createMockModel({ + provider: "openai-codex", + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "edit", arguments: { input: leakyArg } }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + const stream = agentLoop([createUserMessage("edit a fixture")], context, config, undefined, mock.stream); + for await (const _event of stream) { + // drain + } + // The tool ran on the original (unmodified) argument and the turn was not + // retried — a hard-abort would have left `executed` empty and consumed the + // "done" response as a clean retry instead. + expect(executed).toEqual([leakyArg]); + expect(mock.calls).toHaveLength(2); + }); + it("emits an aborted assistant message when cancellation happens before provider events", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], diff --git a/packages/agent/test/harmony-leak.test.ts b/packages/agent/test/harmony-leak.test.ts index 201782e54..bd3d785c6 100644 --- a/packages/agent/test/harmony-leak.test.ts +++ b/packages/agent/test/harmony-leak.test.ts @@ -42,6 +42,13 @@ function makeToolCallMessage(toolName: string, input: string | null, argJson: st return createAssistantMessage([toolCall], "toolUse"); } +// Corpus tool-arg entries are wholly-contaminated args captured from real +// production leaks. detectHarmonyLeakInAssistantMessage keeps the tool_arg +// surface inert without a parse boundary (the production default), so these +// tests inject a boundary at 0 — the entire payload is treated as trailing +// garbage past the valid parse — to exercise tool-arg detection and recovery. +const wholePayloadTrailing = () => 0; + describe("isHarmonyLeakMitigationTarget", () => { it("targets every openai-codex model (don't enumerate ids)", () => { expect(isHarmonyLeakMitigationTarget(codexModel)).toBe(true); @@ -55,7 +62,10 @@ describe("isHarmonyLeakMitigationTarget", () => { describe("detectHarmonyLeak — negative cases (must NOT trip)", () => { for (const neg of negatives) { it(neg.name, () => { - const detection = detectHarmonyLeak(neg.input, "tool_arg"); + // Base content guards (co-signal requirement, fence exemption) are + // surface-independent; the extra `tool_arg` `T`-gate would mask them, so + // assert them on a surface without it — a pass proves the heuristics. + const detection = detectHarmonyLeak(neg.input, "assistant_text"); expect(detection).toBeUndefined(); }); } @@ -79,8 +89,12 @@ describe("detectHarmonyLeak — positive corpus cases (must trip with co-signal) const surfaceText = pos.input ?? pos.argJson; if (surfaceText === null) continue; it(`${pos.id} (${pos.kind}) trips with co-signals`, () => { + // Production feeds no boundary, so tool_arg stays inert. These corpus + // payloads are wholly-contaminated args from real leaks, so a boundary at + // 0 treats the whole payload as trailing and exercises the heuristics. const detection = detectHarmonyLeak(surfaceText, "tool_arg", { toolName: pos.kind === "eval" ? "eval" : "edit", + parsedEnd: 0, }); expect(detection).toBeDefined(); // Every signal that did fire must include `M` plus at least one co-signal. @@ -92,12 +106,41 @@ describe("detectHarmonyLeak — positive corpus cases (must trip with co-signal) } }); +describe("detectHarmonyLeak — tool_arg T-gate", () => { + // The strongest co-signals (channel word + marker + CJK) that a legitimate + // edit of these very fixtures would carry inside its `input` argument. + const embedded = "header line one\nanalysis to=functions.edit code 大发官网"; + + it("does not trip on tool_arg without a parse boundary (no false hard-abort)", () => { + expect(detectHarmonyLeak(embedded, "tool_arg")).toBeUndefined(); + }); + + it("trips the same content on a non-tool surface (gate is tool_arg-specific)", () => { + const detection = detectHarmonyLeak(embedded, "assistant_text"); + expect(detection).toBeDefined(); + expect(detection!.signals.some(s => s.classes.includes("M"))).toBe(true); + }); + + it("does not trip when the marker precedes the parse boundary (embedded content)", () => { + // Whole payload is structurally valid → marker is before parsedEnd → no `T`. + expect(detectHarmonyLeak(embedded, "tool_arg", { parsedEnd: embedded.length })).toBeUndefined(); + }); + + it("trips when the marker trails the parse boundary (real leak signature)", () => { + const boundary = embedded.indexOf("analysis"); + const detection = detectHarmonyLeak(embedded, "tool_arg", { parsedEnd: boundary }); + expect(detection).toBeDefined(); + // Every fired signal carries the `T` co-signal the gate requires. + expect(detection!.signals.every(s => s.classes.includes("T"))).toBe(true); + }); +}); + describe("recoverHarmonyToolCall — edit DSL", () => { const editDsl = positives.filter(p => p.kind === "edit_dsl"); for (const fix of editDsl) { it(`${fix.id}: produces an args-truncated message ending with the *** Abort sentinel`, () => { const message = makeToolCallMessage("edit", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message); + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing); expect(detection).toBeDefined(); const recovered = recoverHarmonyToolCall(message, detection!); expect(recovered).toBeDefined(); @@ -125,9 +168,9 @@ describe("recoverHarmonyToolCall — edit DSL", () => { it("idempotence: re-running detect+recover on the cleaned message is a no-op", () => { const fix = editDsl[0]; const message = makeToolCallMessage("edit", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message)!; + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing)!; const recovered = recoverHarmonyToolCall(message, detection)!; - const second = detectHarmonyLeakInAssistantMessage(recovered.message); + const second = detectHarmonyLeakInAssistantMessage(recovered.message, wholePayloadTrailing); expect(second).toBeUndefined(); }); @@ -137,7 +180,7 @@ describe("recoverHarmonyToolCall — edit DSL", () => { const applyPatchInput = "*** Begin Patch\n*** Update File: a.ts\n@@\n-old\n+new\n*** End Patch\n analysis to=functions.edit code 大发官网"; const message = makeToolCallMessage("edit", applyPatchInput, null); - const detection = detectHarmonyLeakInAssistantMessage(message)!; + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing)!; expect(detection).toBeDefined(); const recovered = recoverHarmonyToolCall(message, detection); expect(recovered).toBeUndefined(); @@ -148,7 +191,7 @@ describe("recoverHarmonyToolCall — edit JSON-schema (must NOT recover)", () => for (const fix of positives.filter(p => p.kind === "edit_json")) { it(`${fix.id}: detects but refuses to recover`, () => { const message = makeToolCallMessage("edit", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message); + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing); expect(detection).toBeDefined(); const recovered = recoverHarmonyToolCall(message, detection!); // argJson cases either lack a string `input` field, or their `input` @@ -162,7 +205,7 @@ describe("recoverHarmonyToolCall — eval", () => { for (const fix of positives.filter(p => p.kind === "eval")) { it(`${fix.id}: cleaned input ends with *** Abort sentinel`, () => { const message = makeToolCallMessage("eval", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message); + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing); expect(detection).toBeDefined(); const recovered = recoverHarmonyToolCall(message, detection!); expect(recovered).toBeDefined(); @@ -182,7 +225,7 @@ describe("recoverHarmonyToolCall — unsupported tools", () => { '{"path":"src/foo.ts","sel":"raw"}' /* legitimate-looking */ + " \tchangedFiles to=functions.read code 天天中彩票"; const message = makeToolCallMessage("read", text, null); - const detection = detectHarmonyLeakInAssistantMessage(message); + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing); // Detector trips because of `G` (changedFiles) + `M`. expect(detection).toBeDefined(); // But `read` is not in RECOVERY_REGISTRY, so no recovery offered. @@ -195,7 +238,7 @@ describe("extractHarmonyRemoved", () => { it("returns the contaminated tail of a tool argument", () => { const fix = positives.filter(p => p.kind === "edit_json")[0]; const message = makeToolCallMessage("edit", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message)!; + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing)!; const removed = extractHarmonyRemoved(message, detection); expect(removed.length).toBeGreaterThan(0); expect(removed.startsWith("to=functions.")).toBe(true); @@ -215,7 +258,7 @@ describe("createHarmonyAuditEvent", () => { it("captures sha + redacted preview by default; raw blob hidden", () => { const fix = positives.filter(p => p.kind === "edit_dsl")[0]; const message = makeToolCallMessage("edit", fix.input, fix.argJson); - const detection = detectHarmonyLeakInAssistantMessage(message)!; + const detection = detectHarmonyLeakInAssistantMessage(message, wholePayloadTrailing)!; const recovered = recoverHarmonyToolCall(message, detection)!; const event = createHarmonyAuditEvent({ action: "truncate_resume", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f6c1cbda..d25584946 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,45 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 +### Added + +- Added `AnthropicMessagesClient` and related Anthropic wire types/errors via `anthropic-client` export so callers can build a standalone Anthropic Messages client without depending on `@anthropic-ai/sdk` +- Added `parseClaudeRateLimitHeaders` and `AuthStorage.ingestUsageHeaders` so Anthropic rate-limit response headers can warm the per-credential usage cache with throttling while preserving per-tier data from the last full usage report. + +### Changed + +- Changed Anthropic request handling to use the package-local `AnthropicMessagesClient` implementation instead of `@anthropic-ai/sdk` as the default transport +- Updated the `AnthropicOptions.client` surface to accept any `AnthropicMessagesClientLike` implementation with `messages.create`, enabling custom compatible clients +- Changed generated OAuth metadata `user_id` to use a deterministic `device_id` derived from the install ID instead of a random value +- `claudeCodeVersion` bumped to `2.1.148` to match current Claude Code release. +- `X-Stainless-Package-Version` updated to `0.94.0` (matches the bundled `@anthropic-ai/sdk` version); `X-Stainless-Runtime-Version` pinned to `v24.3.0` (Bun version bundled with CC 2.1.148); `X-Stainless-Os` header key corrected to `X-Stainless-OS`. +- `createClaudeBillingHeader` now emits a deterministic billing header (`cc_version=.; cc_entrypoint=cli; cch=00000;`), where `` is the first 3 hex chars of `SHA-256(salt + msg[4] + msg[7] + msg[20] + version)` instead of random bytes. The fingerprint seed is taken from the first **user** message (skipping synthetic/developer injections), mirroring Claude Code's `computeFingerprintFromMessages`. +- `cch` attestation implemented: `cch=00000` is a placeholder that, for OAuth requests, `wrapFetchForCch` rewrites on the wire to `XXHash64(body, 0x4D659218E32A3268) & 0xFFFFF` formatted as 5 lowercase hex chars, computed in-place via `Bun.hash.xxHash64`. The rewrite is anchored to the `system[0]` billing-header prefix so user content is never mutated, and is installed only when a billing-header prefix is present (OAuth turns). +- `anthropic-beta` header set for OAuth model discovery and Claude usage-API requests expanded to add `context-1m-2025-08-07`, `redact-thinking-2026-02-12`, `mid-conversation-system-2026-04-07`, `advanced-tool-use-2025-11-20`, `effort-2025-11-24`, and `extended-cache-ttl-2025-04-11`. The usage-API `user-agent` is bumped to `claude-cli/2.1.158 (external, cli)`. +- Reasoning models now append `effort-2025-11-24` to the per-request `Anthropic-Beta` header (matches Claude Code). +- `buildAnthropicSystemBlocks` (CC-instruction mode) now emits the same 3-block layout as Claude Code: billing header (never cached), system instruction (cached), all user content merged into one block with `\n\n` (cached). Previously emitted one block per item with cache only on the last, which fingerprinted the caller by block count. +- `applyPromptCaching` now matches Claude Code's breakpoint layout: 2 system (instruction + merged content) + 2 message, with no tool breakpoint. The tool breakpoint was redundant — tools follow system in the token sequence, so when system changes the tool cache prefix also changes. The instruction block (system[1]) is stable across every request and now gets its own guaranteed-hit breakpoint. +- `applyPromptCaching` now caches the last two messages regardless of role instead of the last two *user* messages. The penultimate assistant message (tool calls + response from the previous turn) is larger and more recently created than the penultimate user message, making it the higher-value cache target. +- OAuth scope set expanded: added `user:sessions:claude_code`, `user:mcp_servers`, `user:file_upload`. `AUTHORIZE_URL` stays at `claude.ai/oauth/authorize` and `TOKEN_URL` stays at `api.anthropic.com/v1/oauth/token` — the `platform.claude.com` equivalents are CC's console-credential flow and do not grant `user:inference`, which OMP requires for direct OAuth-token inference. +- Token refresh POST now sends `anthropic-beta: oauth-2025-04-20` and `User-Agent: anthropic-sdk-typescript/0.94.0 userOAuthProvider` (CC sends these on refresh but not on the initial code exchange). + +### Fixed + +- Fixed tool argument validation to wrap a plain string in a singleton array when the schema requires an array, allowing tool-level path/list normalization to recover from bare string arguments. +- Restored `eager_input_streaming` and strict flags on OAuth Anthropic tool definitions when model compatibility allows eager streaming. +- Fixed OAuth stream calls with injected custom clients missing a `beta` client by falling back to `client.messages.create` instead of requiring `client.beta.messages.create` +- Fixed direct use of internal API client typing so retry/timeouts and malformed-error classification remain compatible while not requiring the external SDK +- Fixed Cursor provider requests failing with `Cannot send empty user message to Cursor API` after tool-result history by selecting the latest user/developer turn instead of assuming the final context message is the active user turn. +- Fixed Anthropic web search dropping `ANTHROPIC_CUSTOM_HEADERS` when `CLAUDE_CODE_USE_FOUNDRY` was unset, causing 401s from corporate API gateways. `resolveAnthropicCustomHeadersForBaseUrl` now forwards the parsed headers whenever the base URL is non-Anthropic (or Foundry is enabled), and `buildAnthropicSearchHeaders` threads them through `buildAnthropicHeaders` so the search and streaming paths behave identically ([#1693](https://github.com/can1357/oh-my-pi/issues/1693)). +- Fixed OpenCode Go Anthropic-format models such as `qwen3.7-max` sending Anthropic `X-Api-Key` auth alongside the OpenCode bearer token, avoiding spurious Alibaba `401 Invalid API-key provided` errors. ([#1661](https://github.com/can1357/oh-my-pi/issues/1661)) +- Fixed OAuth token exchange and refresh flows to fetch Claude CLI bootstrap identity when token responses omit account information, so `accountId` and `email` are now recovered when available +- Fixed Anthropic thinking traces being lost on direct OAuth requests. OAuth requests no longer send `redact-thinking-2026-02-12` unless thinking is explicitly hidden, Opus 4.7+ adaptive thinking opts into `display: "summarized"`, and the top user-facing thinking tier now sends Anthropic's `output_config.effort = "max"` rather than the next-lower `"xhigh"` tier. + +### Removed + +- Removed the `@anthropic-ai/sdk` runtime dependency. The Anthropic provider now uses the package-local `AnthropicMessagesClient` and hand-maintained wire types in `providers/anthropic-wire.ts`; the SDK was only ever used for URL assembly, auth-header injection, bounded retries, the pre-response timeout, and HTTP-error-to-status mapping, all of which are reproduced with identical observable behavior. + ## [15.7.5] - 2026-06-01 ### Added @@ -43,6 +82,7 @@ - Fixed OpenCode-Go dynamic model refresh downgrading `qwen3.7-max` from Anthropic Messages to OpenAI-compatible transport, which caused `401 Model qwen3.7-max is not supported for format oa-compat` after `/v1/models` cache refreshes. ## [15.5.12] - 2026-05-29 + ### Removed - Removed ANTML stream markup healing for `antml:function_calls` and `antml:thinking` envelopes, so Anthropic-compatible providers no longer parse those tags into `toolCall`/`thinking` events @@ -96,13 +136,12 @@ - Fixed `streamSimple` to retry on usage-limit errors (including message-only error events) before any content is emitted, so `onAuthError` can rotate credentials automatically - Fixed auth-gateway error classification to extract embedded status codes and use word-boundary matching, so `GenerateContentRequest` and similar messages are no longer misreported as rate-limit errors - Fixed `checkCredentials` to handle `completionProbe` exceptions by recording the failure in `CredentialHealthResult.completion.reason` while still returning the usage probe result - -### Fixed - - Fixed Google Vertex's bundled model list to use the authoritative models.dev catalog, including MaaS entries such as `deepseek-ai/deepseek-v3.2-maas` and removing retired Gemini 1.5 fallbacks. ([#1456](https://github.com/can1357/oh-my-pi/issues/1456)) ## [15.5.7] - 2026-05-27 + ### Added + - `SimpleStreamOptions.openrouterVariant` (`"nitro"`, `"floor"`, `"online"`, `"exacto"`, …) — when set, appends `:` to OpenRouter model IDs at request time, leaving ids that already carry an explicit `:suffix` untouched. Plumbed through `openai-completions` and the pi-native gateway forwarder. - xAI Grok OAuth (SuperGrok Subscription) provider in `/login`. Loopback PKCE flow on `127.0.0.1:56121`; the token unlocks Grok-4.x chat. Ported from NousResearch/hermes-agent (MIT). @@ -119,6 +158,7 @@ - Fixed OpenRouter DeepSeek V4 tool-call follow-up requests replaying normalized `reasoning` as-is instead of DeepSeek's required `reasoning_content`, which caused HTTP 400 errors in thinking mode. ([#1445](https://github.com/can1357/oh-my-pi/issues/1445)) ## [15.5.6] - 2026-05-27 + ### Added - Added `PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS` to control how long an idle Codex WebSocket stays eligible for reuse, with `0` disabling the check @@ -136,11 +176,14 @@ - Added `PI_CODEX_WEBSOCKET_PING_INTERVAL_MS` to configure the interval for Codex WebSocket protocol ping heartbeats - Added `PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS` to configure the Codex WebSocket pong timeout used to detect unresponsive connections - Added `PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY` to configure the maximum buffered Codex WebSocket inbound queue size before transport fallback +- Added `parseStreamingJsonThrottled` to `@oh-my-pi/pi-ai/utils/json-parse` — a per-delta wrapper around `parseStreamingJson` that skips re-parses until the buffer has grown by `minGrowthBytes` (default 256). Wired into the streaming hot path of every provider's tool-call argument accumulator (`anthropic`, `amazon-bedrock`, `openai-completions`, `openai-codex-responses`, `openai-responses-shared`) so per-delta cost is O(N) in total buffer length instead of O(N²). Each provider's `toolcall_end` still runs a final unthrottled parse, so the published `block.arguments` is unchanged. +- Added named-tool routing support to Google providers: `GoogleSharedStreamOptions.toolChoice` and `GoogleGeminiCliOptions.toolChoice` now accept `{ mode: "ANY"; allowedFunctionNames: [string, ...string[]] }` in addition to the string forms. `mapGoogleToolChoice` converts `ToolChoice` objects of shape `{ type: "tool" | "function", name }` to the wire form. Mirrors the equivalent Anthropic mapper. ### Changed - Improved Codex WebSocket timeout diagnostics to include last event type and time since last progress event - Enhanced Codex WebSocket error classification to recognize ping, pong, send, and queue-overflow failures as retryable +- Changed `mapGoogleToolChoice` to be exported from `@oh-my-pi/pi-ai/stream` so callers can build the wire-shape allow-list directly without re-deriving it. ### Fixed @@ -149,21 +192,10 @@ - Fixed Codex WebSocket pong timeout detection by tracking pong events and failing the connection when no pong is received within the configured timeout - Fixed Anthropic streaming to suppress hallucinated meta-prompt thinking blocks (the recent "I don't see any current rewritten thinking..." regression). When the marker phrase `rewritten thinking` appears in a streamed thinking summary the block is collapsed to a plain `Thinking...` placeholder and its signature is dropped so subsequent turns can't re-anchor on the garbled chain. - Fixed Codex WebSocket silent stalls by adding protocol pings, inbound queue bounding, clearer idle-timeout diagnostics, and SDK retry clamping for first-event timeouts. - -### Fixed - - Fixed Synthetic model discovery to treat the provider `/models` response as authoritative so deprecated bundled IDs are pruned from the runtime cache, and changed Synthetic login validation to avoid probing a specific model ([#1417](https://github.com/can1357/oh-my-pi/issues/1417)). -### Added - -- Added `parseStreamingJsonThrottled` to `@oh-my-pi/pi-ai/utils/json-parse` — a per-delta wrapper around `parseStreamingJson` that skips re-parses until the buffer has grown by `minGrowthBytes` (default 256). Wired into the streaming hot path of every provider's tool-call argument accumulator (`anthropic`, `amazon-bedrock`, `openai-completions`, `openai-codex-responses`, `openai-responses-shared`) so per-delta cost is O(N) in total buffer length instead of O(N²). Each provider's `toolcall_end` still runs a final unthrottled parse, so the published `block.arguments` is unchanged. -- Added named-tool routing support to Google providers: `GoogleSharedStreamOptions.toolChoice` and `GoogleGeminiCliOptions.toolChoice` now accept `{ mode: "ANY"; allowedFunctionNames: [string, ...string[]] }` in addition to the string forms. `mapGoogleToolChoice` converts `ToolChoice` objects of shape `{ type: "tool" | "function", name }` to the wire form. Mirrors the equivalent Anthropic mapper. - -### Changed - -- Changed `mapGoogleToolChoice` to be exported from `@oh-my-pi/pi-ai/stream` so callers can build the wire-shape allow-list directly without re-deriving it. - ## [15.5.0] - 2026-05-26 + ### Added - Added `zhipu-coding-plan` provider for Zhipu (智谱) BigModel's domestic coding-plan SKU at `https://open.bigmodel.cn/api/coding/paas/v4`, with dynamic model discovery (`ZHIPU_API_KEY`), zai-format thinking, `reasoning_content` field, and OAuth login flow ([#1340](https://github.com/can1357/oh-my-pi/issues/1340)). @@ -191,6 +223,7 @@ - Fixed OpenCode Zen `big-pickle` follow-up requests replaying assistant tool-call turns without DeepSeek-required `reasoning_content`, which caused HTTP 400 errors in thinking mode. ## [15.4.1] - 2026-05-26 + ### Added - Added `isOpenAICompletionsProgressChunk` export to identify real progress chunks vs. keepalives in OpenAI completions streams @@ -224,6 +257,7 @@ - Fixed z.ai/GLM-via-OpenRouter subagent stalls where no-op keepalive chunks reset the idle watchdog indefinitely by filtering non-progress items before resetting the deadline ## [15.4.0] - 2026-05-26 + ### Breaking Changes - Removed `findAnthropicAuth` from `anthropic-auth` and replaced store-driven auth discovery with `buildAnthropicAuthConfig`, requiring callers to provide an already-resolved API key before building Anthropic auth config @@ -260,6 +294,7 @@ - Fixed `pi-ai login moonshot` failing with `invalid temperature: only 1 is allowed for this model` (HTTP 400) because the API-key validator probed `kimi-k2.5` with `temperature: 0`. Moonshot login now validates against `GET /v1/models`, matching the DeepSeek/Fireworks/NanoGPT/ZenMux pattern and authenticating the key without invoking model-specific parameter restrictions. ## [15.3.2] - 2026-05-25 + ### Added - Added `GET /v1/snapshot/stream` for live auth-broker snapshot updates via SSE with `snapshot`, `entry`, and `removed` event frames @@ -316,6 +351,7 @@ - Fixed `/btw` (and IRC background replies) returning a `BedrockException` 400 (`The toolConfig field must be defined when using toolUse and toolResult content blocks.`) on LiteLLM → Bedrock once the session has tool-call history. Two source fixes in `buildParams`: (1) `if (context.tools)` → `if (context.tools?.length)` so an explicit `context.tools = []` (the /btw opt-out) never routes through `convertTools` and never emits an empty `"tools"` array; (2) `else if (hasToolHistory(...))` → `else if (context.tools === undefined && hasToolHistory(...))` so the Anthropic-proxy sentinel that injects `tools: []` for tool-history turns is suppressed when the caller explicitly opted out, preventing it from re-introducing the empty array. As defence-in-depth, `tool_choice: "none"` is also dropped when the resolved tools list is missing or empty. ([#1227](https://github.com/can1357/oh-my-pi/issues/1227)) ## [15.1.8] - 2026-05-20 + ### Added - Added Fireworks Fire Pass as a separate `firepass` provider with API-key login flow, bundled `kimi-k2.6-turbo` model entry (Kimi K2.6 Turbo), and wire-id translation from the friendly catalog id to the `accounts/fireworks/routers/kimi-k2p6-turbo` router endpoint. Fire Pass keys (`fpk_…`) authorize only the dedicated router and reject `/v1/models`, so login validation pings chat completions against the router id directly. Extended the openai-completions Kimi-family safety net so the firepass entry inherits the per-Fireworks-docs "always send `max_tokens`" default ([Kimi K2 guide](https://docs.fireworks.ai/models/kimi-k2)); the router's accepted `reasoning_effort` set includes `xhigh`, so it is forwarded verbatim rather than remapped. See https://docs.fireworks.ai/firepass. @@ -327,6 +363,7 @@ - Fixed Perplexity OAuth credentials being treated as expired one hour after login. `getJwtExpiry` was fabricating `expires = now + 1h` whenever the JWT had no `exp` claim (the common case — Perplexity sessions are server-side). Once the hour elapsed, `getOAuthApiKey` would mark the cred expired and the search provider's loader would silently skip it, surfacing as "logged out". Logins with no `exp` now persist a far-future sentinel; `getOAuthApiKey` also normalizes any stale `expires` written by older builds. ## [15.1.7] - 2026-05-19 + ### Added - Added Anthropic realization of `serviceTier: "priority"`. The anthropic-messages provider now sets `speed: "fast"` on the request and appends the `fast-mode-2026-02-01` beta to `Anthropic-Beta` whenever the caller passes `serviceTier: "priority"`. When the server rejects an unsupported model with `invalid_request_error`, the provider transparently retries the same turn without the fast-mode signal (mirroring the strict-tools fallback pattern), persists the disable via a new `providerSessionState.fastModeDisabled` flag so subsequent requests in the session skip the field, and surfaces the action via the new `AssistantMessage.disabledFeatures` array (id `"priority"`) so callers can sync user-facing toggles. A new `clearAnthropicFastModeFallback(providerSessionState)` helper lets callers re-arm priority after the auto-fallback fired. @@ -345,6 +382,7 @@ - Fixed `pi-ai login ` crashing with `Unknown provider` for providers that only the `auth-storage` `login()` switch knew about (perplexity, alibaba-coding-plan, gitlab-duo, huggingface, opencode-zen/go, lm-studio, ollama, cerebras, fireworks, qianfan, synthetic, venice, litellm, moonshot, together, cloudflare/vercel ai gateways, vllm, qwen-portal, nvidia, xiaomi, and any custom OAuth provider). The CLI now delegates to `SqliteAuthCredentialStore.login()` instead of duplicating a smaller switch, so the auth-broker `omp auth-broker login ` flow works for every registered OAuth provider. ## [15.1.4] - 2026-05-19 + ### Changed - Updated auth-gateway format and pi-native request handling to invalidate the failed API key and retry the provider request with a replacement key when authentication fails @@ -358,6 +396,7 @@ - Added `credential_process` support to the Bedrock provider's AWS credential resolver so profiles delegating to external brokers (`aws-vault`, `granted`, in-house tools) resolve instead of falling through to `Unable to resolve AWS credentials`. Parses the AWS SDK `Version: 1` JSON envelope, honors `Expiration` in the per-profile cache, propagates `AbortSignal` to the spawned helper, routes Windows `.cmd`/`.bat` helpers through `cmd.exe /c`, and ships a POSIX-shell-style tokenizer that preserves backslashes inside double quotes so Windows paths survive ([#1142](https://github.com/can1357/oh-my-pi/issues/1142)) ## [15.1.3] - 2026-05-17 + ### Breaking Changes - Changed `AuthBrokerClient.fetchSnapshot()` to return status-based results (`200` or `304`) instead of always returning a raw snapshot body, so callers now need to branch on `status` @@ -443,6 +482,7 @@ - Hardened auth-gateway bearer-token checks with constant-time comparison to avoid timing-side-channel leaks ## [15.1.2] - 2026-05-15 + ### Breaking Changes - Rejected draft-07 tuple and dependency keywords (`items` arrays, `dependencies`, `additionalItems`) in JSON Schema validation @@ -511,12 +551,14 @@ - Fixed mock provider auto-generated tool-call IDs to use a per-instance counter (now reset by `reset()`), so test order no longer affects IDs across `createMockModel()` instances ## [15.0.2] - 2026-05-15 + ### Fixed - Fixed `StreamOptions.fetch` typing to accept fetch-compatible override functions that do not expose `preconnect`, allowing custom fetch implementations to be used without type errors across runtimes - Fixed Moonshot Kimi K2.6 forced tool calls to send `thinking: { type: "disabled" }`, avoiding `tool_choice 'specified' is incompatible with thinking enabled` 400s while preserving the requested named tool ([#1077](https://github.com/can1357/oh-my-pi/issues/1077)). ## [15.0.1] - 2026-05-14 + ### Breaking Changes - Increased the minimum Bun runtime version to `>=1.3.14` for the `@aws-?` package @@ -536,22 +578,22 @@ - Fixed OAuth credentials being silently disabled when two omp processes (or any two `AuthStorage` instances sharing a `agent.db`) race on token refresh. Anthropic rotates refresh tokens on every use, so the loser's `invalid_grant` response previously soft-deleted the row that the winner just rotated, forcing the user to `/login` again. `#tryOAuthCredential` now re-reads the row from disk before declaring a definitive failure: if the persisted `refresh` differs from the snapshot it tried, the peer-rotated credential is reloaded and the request retries against the fresh token instead of disabling the live row. - Closed a remaining race window in OAuth refresh-failure handling: between re-reading the credential row to check for peer rotation and the subsequent soft-delete, another process could still complete a refresh and rotate the row, leaving us to disable the freshly-rotated credential by `id`. The disable now runs as a single CAS update conditioned on the row's `data` still matching the snapshot we tried to refresh, and on `disabled_cause IS NULL`. If the CAS reports 0 rows changed (peer rotation, or row already disabled by a concurrent failure on the same snapshot), we reload from disk and retry instead of mutating the wrong row or emitting a spurious `credential_disabled` event. -### Changed -- Lowered the default steady-state stream idle timeout from 120s to 30s while preserving the existing environment overrides. - -### Fixed - Lazy built-in provider streams now enforce the shared idle watchdog and abort stalled provider requests, so session auto-retry can continue after transient network drops instead of remaining stuck. Caller aborts still terminate as aborted. +### Changed + +- Lowered the default steady-state stream idle timeout from 120s to 30s while preserving the existing environment overrides. + ## [14.9.3] - 2026-05-10 ### Fixed + - Anthropic provider now retries generic transient connect failures (`unable to connect`, `fetch failed`, `connection error`, etc.) by falling back to the shared `isRetryableError` allowlist after the provider-specific patterns. Previously these errors bypassed the hand-curated regex in `isProviderRetryableError` and aborted the stream on the first attempt, while the OpenAI SDK and Codex `fetchWithRetry` paths already handled them. ## [14.9.0] - 2026-05-10 -### Added - ### Fixed + - Fixed silent forwarding of image content (for example Python plot output rendered in the terminal) to models without vision support, which produced opaque 404 errors from upstream. Image blocks are now stripped and replaced with a `[image omitted: model does not support vision]` placeholder for non-vision models, including tool-result payloads ([#967](https://github.com/can1357/oh-my-pi/issues/967), [#968](https://github.com/can1357/oh-my-pi/issues/968)). - Added `AuthStorage` `onCredentialDisabled` callback (sync or async) so embedders can react when a credential is automatically disabled (e.g. OAuth refresh fails with `invalid_grant`) — useful for surfacing a banner or auto-launching a re-login flow instead of letting the credential silently disappear. Sync throws and async rejections are both caught and logged so a misbehaving subscriber cannot break the disable path. @@ -568,6 +610,7 @@ ## [14.8.0] - 2026-05-09 ### Fixed + - Fixed Gemini 3 Pro thinking metadata so `medium` effort is rejected with the expected error instead of being silently accepted: `ThinkingConfig` now carries an optional explicit `levels` list that survives `expandEffortRange`, letting non-contiguous supported sets (e.g. `[low, high]`) round-trip through enrichment. - Fixed Kimi Code OAuth expiry handling to refresh access tokens 5 minutes before server expiry, avoiding daily 401s from using tokens right up to the cutoff. - Fixed OpenAI Responses custom tool replay to preserve custom tool call item IDs with the `ctc_` prefix instead of rewriting them as `fc_` function-call IDs ([#977](https://github.com/can1357/oh-my-pi/issues/977)). @@ -581,6 +624,7 @@ ### Changed - Changed OpenAI Responses, Azure OpenAI Responses, and OpenAI Codex providers to omit `reasoning.summary` from requests when `reasoningSummary` is explicitly `null` (previously fell back to `"auto"`). + ## [14.7.5] - 2026-05-07 ### Added @@ -600,6 +644,7 @@ - Fixed local Ollama model discovery to apply `/api/show` thinking and vision capabilities in addition to native context windows ([#928](https://github.com/can1357/oh-my-pi/issues/928)). ## [14.7.0] - 2026-05-04 + ### Breaking Changes - Changed `Context.systemPrompt` from a string to `string[]`, so callers must now pass an array of prompts instead of a single string @@ -631,6 +676,7 @@ - Fixed OpenAI Codex websocket continuations to retry with full context when `previous_response_id` expires server-side instead of surfacing `previous_response_not_found`. ## [14.6.2] - 2026-05-03 + ### Added - Added `EventStream.fail(err)` method to terminate the async iterator with an error, enabling consumers to catch stream-level failures via `for await` without hanging @@ -668,6 +714,7 @@ - Fixed OpenAI Codex websocket append reuse after `response.completed` terminal events. ## [14.5.14] - 2026-05-01 + ### Added - Added package-level `google-gemini-headers` exports (`getGeminiCliHeaders`, `getGeminiCliUserAgent`, `getAntigravityHeaders`, `extractRetryDelay`, and `ANTIGRAVITY_SYSTEM_INSTRUCTION`) for header and retry handling reuse without importing full Google providers @@ -927,6 +974,7 @@ - Added `thinkingSignature` field to thinking content blocks to preserve the original reasoning field name (e.g., `reasoning_text`, `reasoning_content`) for accurate follow-up requests - Added first-event timeout detection for streaming responses to abort stuck requests before user-visible content arrives - Added `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` environment variable to configure first-event timeout (defaults to 15 seconds or idle timeout, whichever is lower) +- Added Vercel AI Gateway to `/login` providers for interactive API key setup ### Changed @@ -936,13 +984,6 @@ - Fixed Anthropic stream timeout errors to be properly retried by recognizing first-event timeout messages - Fixed stream stall detection to distinguish between first-event timeouts and idle timeouts, enabling faster recovery for stuck connections - -### Added - -- Added Vercel AI Gateway to `/login` providers for interactive API key setup - -### Fixed - - Fixed `omp commit` failing with HTTP 400 errors when using reasoning-enabled models on OpenAI-compatible endpoints that don't support the `developer` role (e.g., GitHub Copilot, custom proxies). Now falls back to `system` role when `developer` is unsupported. ## [13.17.0] - 2026-03-30 @@ -2832,19 +2873,16 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ### Added - **`agentLoopContinue` function**: Continue an agent loop from existing context without adding a new user message. Validates that the last message is `user` or `toolResult`. Useful for retry after context overflow or resuming from manually-added tool results. - -### Breaking Changes - -- Removed provider-level tool argument validation. Validation now happens in `agentLoop` via `executeToolCalls`, allowing models to retry on validation errors. For manual tool execution, use `validateToolCall(tools, toolCall)` or `validateToolArguments(tool, toolCall)`. - -### Added - - Added `validateToolCall(tools, toolCall)` helper that finds the tool by name and validates arguments. - **OpenAI compatibility overrides**: Added `compat` field to `Model` for `openai-completions` API, allowing explicit configuration of provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Falls back to URL-based detection if not set. Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR) - **xhigh reasoning level**: Added `xhigh` to `ReasoningEffort` type for OpenAI codex-max models. For non-OpenAI providers (Anthropic, Google), `xhigh` is automatically mapped to `high`. ([#143](https://github.com/badlogic/pi-mono/issues/143)) +### Breaking Changes + +- Removed provider-level tool argument validation. Validation now happens in `agentLoop` via `executeToolCalls`, allowing models to retry on validation errors. For manual tool execution, use `validateToolCall(tools, toolCall)` or `validateToolArguments(tool, toolCall)`. + ### Changed - **Updated SDK versions**: OpenAI SDK 5.21.0 → 6.10.0, Anthropic SDK 0.61.0 → 0.71.2, Google GenAI SDK 1.30.0 → 1.31.0 diff --git a/packages/ai/package.json b/packages/ai/package.json index 824cf269b..ea3f49a18 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "15.7.6", + "version": "15.8.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -38,7 +38,6 @@ "generate-models": "bun scripts/generate-models.ts" }, "dependencies": { - "@anthropic-ai/sdk": "catalog:", "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 65549e02f..ed27cdb2a 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -460,6 +460,7 @@ const USAGE_CACHE_PREFIX = "usage_cache:"; // each credential's last-known value sticks visible while peers retry. UI // data (5h / 7d / monthly limits) is fine being a few minutes stale. const USAGE_REPORT_TTL_MS = 5 * 60_000; +const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000; const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000; /** * Per-credential cool-down after a usage fetch fails. While this window is @@ -750,6 +751,7 @@ export class AuthStorage { #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; #usageCache: UsageCache; #usageRequestInFlight: Map> = new Map(); + #usageHeaderIngestAt: Map = new Map(); #usageReportsInFlight: Map> = new Map(); #usageFetch: typeof fetch; #usageRequestTimeoutMs: number; @@ -1393,14 +1395,7 @@ export class AuthStorage { ); } - /** - * Get the OAuth `accountId` for a provider, preferring the credential that is - * session-sticky for `sessionId` when multiple OAuth credentials are configured. - * Falls back to the first OAuth credential when no session preference exists (e.g. - * first call before any `getApiKey` has been issued, or single-credential setups). - * Returns `undefined` when no OAuth credential carries an `accountId`. - */ - getOAuthAccountId(provider: string, sessionId?: string): string | undefined { + #resolveActiveOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined { const allCredentials = this.#getCredentialsForProvider(provider); const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth"); if (oauthCredentials.length === 0) return undefined; @@ -1427,7 +1422,18 @@ export class AuthStorage { // filtered array would be off-by-N when any non-OAuth credential precedes the // OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order). const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined; - const preferred = stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0]; + return stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0]; + } + + /** + * Get the OAuth `accountId` for a provider, preferring the credential that is + * session-sticky for `sessionId` when multiple OAuth credentials are configured. + * Falls back to the first OAuth credential when no session preference exists (e.g. + * first call before any `getApiKey` has been issued, or single-credential setups). + * Returns `undefined` when no OAuth credential carries an `accountId`. + */ + getOAuthAccountId(provider: string, sessionId?: string): string | undefined { + const preferred = this.#resolveActiveOAuthCredential(provider, sessionId); const accountId = preferred?.accountId; return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined; } @@ -2115,6 +2121,59 @@ export class AuthStorage { return promise; } + ingestUsageHeaders( + provider: Provider, + headers: Record, + options?: { sessionId?: string; baseUrl?: string }, + ): boolean { + if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false; + + const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId); + if (!credential) return false; + + const cacheKey = this.#buildUsageReportCacheKey( + this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl), + ); + const now = Date.now(); + const last = this.#usageHeaderIngestAt.get(cacheKey); + if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false; + + const report = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now); + if (!report) return false; + + const prior = this.#usageCache.getStale(cacheKey)?.value; + let merged = report; + if (prior && Array.isArray(prior.limits)) { + const headerLimitsById = new Map(report.limits.map(limit => [limit.id, limit])); + const limits: UsageLimit[] = []; + for (const limit of prior.limits) { + const replacement = headerLimitsById.get(limit.id); + if (replacement) { + limits.push(replacement); + headerLimitsById.delete(limit.id); + } else { + limits.push(limit); + } + } + for (const limit of headerLimitsById.values()) { + limits.push(limit); + } + merged = { + ...prior, + fetchedAt: now, + limits, + metadata: { + ...(prior.metadata ?? {}), + headersUpdatedAt: now, + }, + }; + } + + this.#usageCache.set(cacheKey, { value: merged, expiresAt: now + USAGE_REPORT_TTL_MS }); + this.#usageHeaderIngestAt.set(cacheKey, now); + return true; + } + #collectUsageRequests(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; }): UsageRequestDescriptor[] { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 213f30a2b..ecdce180e 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -11,6 +11,7 @@ export * from "./models"; export * from "./provider-details"; export * from "./provider-models"; export * from "./providers/anthropic"; +export * from "./providers/anthropic-client"; export * from "./providers/azure-openai-responses"; export type * from "./providers/cursor"; export * from "./providers/gitlab-duo"; diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index 566f55bd9..8dca5ebee 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -15,7 +15,7 @@ import { createBundledReferenceMap, createReferenceResolver } from "./bundled-re const MODELS_DEV_URL = "https://models.dev/api.json"; const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; const ANTHROPIC_OAUTH_BETA = - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05"; + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11"; export interface ModelsDevModel { id?: string; diff --git a/packages/ai/src/providers/anthropic-client.ts b/packages/ai/src/providers/anthropic-client.ts new file mode 100644 index 000000000..0639f6397 --- /dev/null +++ b/packages/ai/src/providers/anthropic-client.ts @@ -0,0 +1,318 @@ +/** + * Minimal HTTP client for the Anthropic Messages API. + * + * pi-ai builds every request header itself (`buildAnthropicHeaders`), serializes + * the body itself (`buildParams`), and parses SSE frames itself + * (`iterateAnthropicEvents`), so the only `@anthropic-ai/sdk` surface this + * package ever exercised was URL assembly, auth-header injection, bounded + * retries, the pre-response timeout, and HTTP-error-to-status mapping. This + * module implements exactly that surface and nothing else. + * + * Behavioral contract (kept compatible with the SDK so downstream error + * classification keeps working): + * - Non-2xx responses throw {@link AnthropicApiError} whose `status` property + * carries the HTTP status and whose message is `" "`. + * - Pre-response timeouts throw {@link AnthropicConnectionTimeoutError} + * ("Request timed out."). + * - Caller aborts throw an `Error` with message "Request was aborted.". + * - Retries: connection errors and 408/409/429/5xx (or `x-should-retry: true`) + * are retried up to `maxRetries` times, honoring `retry-after-ms` / + * `retry-after`, otherwise exponential backoff (0.5s * 2^n, capped at 8s, + * with up to 25% jitter). + */ +import { scheduler } from "node:timers/promises"; +import type { FetchImpl } from "../types"; +import type { MessageCreateParamsStreaming } from "./anthropic-wire"; + +/** Default pre-response timeout, matching the SDK's 10-minute default. */ +const DEFAULT_TIMEOUT_MS = 600_000; +/** Default retry budget, matching the SDK's default. */ +const DEFAULT_MAX_RETRIES = 2; +const INITIAL_RETRY_DELAY_S = 0.5; +const MAX_RETRY_DELAY_S = 8; + +/** Per-request options accepted by {@link AnthropicMessages.create}. */ +export interface AnthropicRequestOptions { + signal?: AbortSignal; + /** Pre-response timeout in milliseconds. */ + timeout?: number; + /** Per-request retry budget override. */ + maxRetries?: number; +} + +/** + * Extra `RequestInit` fields merged into every fetch call. Bun extends + * `RequestInit` with a `tls` option used for the Claude Code TLS profile and + * Foundry mTLS. + */ +export type AnthropicFetchOptions = RequestInit & { + tls?: { + rejectUnauthorized?: boolean; + serverName?: string; + ciphers?: string; + ca?: string | string[]; + cert?: string; + key?: string; + }; +}; + +export interface AnthropicClientOptions { + /** Sent as `X-Api-Key` unless the header is already present in `defaultHeaders`. */ + apiKey?: string | null; + /** Sent as `Authorization: Bearer ` unless the header is already present in `defaultHeaders`. */ + authToken?: string | null; + baseURL?: string | null; + maxRetries?: number; + /** Pre-response timeout in milliseconds. Defaults to 10 minutes. */ + timeout?: number; + defaultHeaders?: Record; + fetch?: FetchImpl; + fetchOptions?: AnthropicFetchOptions; +} + +/** Non-2xx response from the Anthropic API. */ +export class AnthropicApiError extends Error { + readonly status: number; + readonly headers: Headers; + readonly requestId: string | null; + + constructor(status: number, message: string, headers: Headers) { + super(message); + this.name = "AnthropicApiError"; + this.status = status; + this.headers = headers; + this.requestId = headers.get("request-id"); + } + + static async fromResponse(response: Response): Promise { + const body = await response.text().catch(() => ""); + const detail = body.trim() || "status code (no body)"; + return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers); + } +} + +/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */ +export class AnthropicConnectionError extends Error { + constructor(cause: unknown) { + super("Connection error.", { cause }); + this.name = "AnthropicConnectionError"; + } +} + +/** No response headers arrived within the configured request timeout. */ +export class AnthropicConnectionTimeoutError extends Error { + constructor() { + super("Request timed out."); + this.name = "AnthropicConnectionTimeoutError"; + } +} + +function createAbortError(): Error { + return new Error("Request was aborted."); +} + +/** `x-should-retry` override, then 408/409/429/5xx. */ +function shouldRetryResponse(response: Response): boolean { + const shouldRetryHeader = response.headers.get("x-should-retry"); + if (shouldRetryHeader === "true") return true; + if (shouldRetryHeader === "false") return false; + const status = response.status; + return status === 408 || status === 409 || status === 429 || status >= 500; +} + +/** Server-suggested delay (`retry-after-ms`, then `retry-after` seconds or HTTP date). */ +function retryDelayFromHeaders(headers: Headers | undefined): number | undefined { + if (!headers) return undefined; + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs) { + const ms = Number.parseFloat(retryAfterMs); + if (Number.isFinite(ms) && ms >= 0) return ms; + } + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const seconds = Number.parseFloat(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + const dateMs = Date.parse(retryAfter) - Date.now(); + if (Number.isFinite(dateMs) && dateMs >= 0) return dateMs; + } + return undefined; +} + +function defaultRetryDelayMs(attempt: number): number { + const sleepSeconds = Math.min(INITIAL_RETRY_DELAY_S * 2 ** attempt, MAX_RETRY_DELAY_S); + const jitter = 1 - Math.random() * 0.25; + return sleepSeconds * jitter * 1000; +} + +function hasHeaderCaseInsensitive(headers: Record, lowerName: string): boolean { + for (const key in headers) { + if (key.toLowerCase() === lowerName) return true; + } + return false; +} + +/** + * Lazy in-flight request handle. The HTTP request starts on the first + * `asResponse()` call; subsequent calls return the same promise. + * + * Shape-compatible with the SDK's `APIPromise.asResponse()` so + * `getAnthropicStreamResponse` treats internal and injected clients uniformly. + */ +export class AnthropicApiRequest { + #start: () => Promise; + #response: Promise | undefined; + + constructor(start: () => Promise) { + this.#start = start; + } + + asResponse(): Promise { + this.#response ??= this.#start(); + return this.#response; + } +} + +/** + * `messages` resource. `create` lives on the prototype so tests can intercept + * every outgoing request with `vi.spyOn(AnthropicMessages.prototype, "create")`. + */ +export class AnthropicMessages { + #client: AnthropicMessagesClient; + #path: string; + + constructor(client: AnthropicMessagesClient, path: string) { + this.#client = client; + this.#path = path; + } + + create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest { + return this.#client.request(this.#path, params, options); + } +} + +/** + * Structural interface satisfied by both {@link AnthropicMessagesClient} and + * SDK-style clients (e.g. `AnthropicVertex`), so callers can inject an + * alternative Messages-API client via `AnthropicOptions.client`. + */ +export interface AnthropicMessagesClientLike { + messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown }; + beta?: { messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown } }; +} + +export class AnthropicMessagesClient implements AnthropicMessagesClientLike { + readonly messages: AnthropicMessages; + readonly beta: { readonly messages: AnthropicMessages }; + #options: AnthropicClientOptions; + + constructor(options: AnthropicClientOptions) { + this.#options = options; + this.messages = new AnthropicMessages(this, "/v1/messages"); + this.beta = { messages: new AnthropicMessages(this, "/v1/messages?beta=true") }; + } + + request(path: string, params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest { + return new AnthropicApiRequest(() => this.#send(path, params, options)); + } + + #buildHeaders(): Record { + const opts = this.#options; + const defaults = opts.defaultHeaders ?? {}; + const headers: Record = {}; + if (opts.apiKey != null && !hasHeaderCaseInsensitive(defaults, "x-api-key")) { + headers["X-Api-Key"] = opts.apiKey; + } + if (opts.authToken != null && !hasHeaderCaseInsensitive(defaults, "authorization")) { + headers.Authorization = `Bearer ${opts.authToken}`; + } + Object.assign(headers, defaults); + return headers; + } + + async #send( + path: string, + params: MessageCreateParamsStreaming, + options?: AnthropicRequestOptions, + ): Promise { + const opts = this.#options; + const fetchFn: FetchImpl = opts.fetch ?? fetch; + const callerSignal = options?.signal; + const timeoutMs = options?.timeout ?? opts.timeout ?? DEFAULT_TIMEOUT_MS; + const maxRetries = Math.max(0, options?.maxRetries ?? opts.maxRetries ?? DEFAULT_MAX_RETRIES); + const url = `${opts.baseURL ?? "https://api.anthropic.com"}${path}`; + const headers = this.#buildHeaders(); + const body = JSON.stringify(params); + + for (let attempt = 0; ; attempt++) { + if (callerSignal?.aborted) throw createAbortError(); + + let response: Response; + try { + response = await this.#fetchOnce(fetchFn, url, headers, body, timeoutMs, callerSignal); + } catch (error) { + if (callerSignal?.aborted) throw createAbortError(); + if (attempt < maxRetries) { + await this.#backoff(attempt, undefined, callerSignal); + continue; + } + if (error instanceof AnthropicConnectionTimeoutError) throw error; + throw new AnthropicConnectionError(error); + } + + if (response.ok) return response; + + if (attempt < maxRetries && shouldRetryResponse(response)) { + await response.body?.cancel().catch(() => {}); + await this.#backoff(attempt, response.headers, callerSignal); + continue; + } + throw await AnthropicApiError.fromResponse(response); + } + } + + async #fetchOnce( + fetchFn: FetchImpl, + url: string, + headers: Record, + body: string, + timeoutMs: number, + callerSignal: AbortSignal | undefined, + ): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + const onAbort = () => controller.abort(); + callerSignal?.addEventListener("abort", onAbort, { once: true }); + try { + return await fetchFn(url, { + method: "POST", + headers, + body, + signal: controller.signal, + ...(this.#options.fetchOptions ?? {}), + }); + } catch (error) { + if (timedOut && !callerSignal?.aborted) throw new AnthropicConnectionTimeoutError(); + throw error; + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", onAbort); + } + } + + async #backoff( + attempt: number, + responseHeaders: Headers | undefined, + signal: AbortSignal | undefined, + ): Promise { + const delayMs = retryDelayFromHeaders(responseHeaders) ?? defaultRetryDelayMs(attempt); + try { + await scheduler.wait(delayMs, { signal }); + } catch { + throw createAbortError(); + } + } +} diff --git a/packages/ai/src/providers/anthropic-messages-server-schema.ts b/packages/ai/src/providers/anthropic-messages-server-schema.ts index 1ba88d3b2..fffb6f5ad 100644 --- a/packages/ai/src/providers/anthropic-messages-server-schema.ts +++ b/packages/ai/src/providers/anthropic-messages-server-schema.ts @@ -7,6 +7,8 @@ * Used by `anthropic-messages.ts:parseRequest` to validate the inbound JSON * before walking it into pi-ai's canonical `Context`. */ + +import * as z from "zod/v4"; import type { ContentBlockParam, ImageBlockParam, @@ -15,8 +17,7 @@ import type { TextBlockParam, Tool, ToolChoice, -} from "@anthropic-ai/sdk/resources/messages"; -import * as z from "zod/v4"; +} from "./anthropic-wire"; // `cache_control` is accepted and translated to pi-ai's per-request // `cacheRetention` (any `ttl: "1h"` marker upgrades the request to "long"; diff --git a/packages/ai/src/providers/anthropic-wire.ts b/packages/ai/src/providers/anthropic-wire.ts new file mode 100644 index 000000000..463b78101 --- /dev/null +++ b/packages/ai/src/providers/anthropic-wire.ts @@ -0,0 +1,268 @@ +/** + * Anthropic Messages API wire types. + * + * Hand-maintained against https://docs.anthropic.com/en/api/messages so pi-ai + * does not depend on `@anthropic-ai/sdk` for type information. Only the shapes + * this package actually reads or writes are modeled; fields we never touch are + * intentionally omitted. Names mirror the SDK so call sites read the same. + * + * Unlike the SDK, beta fields pi-ai uses (`speed`, `context_management`, + * `output_config.effort`/`task_budget`, `thinking.display`, cache-control + * `scope`, tool `strict`/`eager_input_streaming`, mid-conversation `system` + * role) are first-class here instead of being patched in via casts. + */ +import type { TokenTaskBudget } from "../types"; + +// ─── Cache control ────────────────────────────────────────────────────────── + +/** Ephemeral prefix-cache breakpoint marker. */ +export type CacheControlEphemeral = { + type: "ephemeral"; + ttl?: "1h" | "5m"; + /** Claude Code prompt-caching-scope beta: shares the breakpoint across sessions. */ + scope?: "global"; +}; + +// ─── Content blocks (request) ─────────────────────────────────────────────── + +export type Base64ImageSource = { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; +}; + +export type URLImageSource = { type: "url"; url: string }; + +export type FileImageSource = { type: "file"; file_id: string }; + +export type ImageSource = Base64ImageSource | URLImageSource | FileImageSource; + +export type TextBlockParam = { + type: "text"; + text: string; + cache_control?: CacheControlEphemeral | null; +}; + +export type ImageBlockParam = { + type: "image"; + source: ImageSource; + cache_control?: CacheControlEphemeral | null; +}; + +export type ToolUseBlockParam = { + type: "tool_use"; + id: string; + name: string; + input: unknown; + cache_control?: CacheControlEphemeral | null; +}; + +export type ToolResultBlockParam = { + type: "tool_result"; + tool_use_id: string; + content?: string | Array; + is_error?: boolean; + cache_control?: CacheControlEphemeral | null; +}; + +export type ThinkingBlockParam = { + type: "thinking"; + thinking: string; + signature: string; +}; + +export type RedactedThinkingBlockParam = { + type: "redacted_thinking"; + data: string; +}; + +export type ContentBlockParam = + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam; + +/** + * A single conversation turn. + * + * `system` is the Opus 4.8+ mid-conversation system role + * (`mid-conversation-system-2026-04-07` beta); the public API otherwise only + * accepts `user` / `assistant`. + */ +export type MessageParam = { + role: "user" | "assistant" | "system"; + content: string | ContentBlockParam[]; +}; + +// ─── Tools ────────────────────────────────────────────────────────────────── + +export type ToolInputSchema = { + type: "object"; + properties?: unknown | null; + required?: string[] | null; + [k: string]: unknown; +}; + +export type Tool = { + name: string; + description?: string; + input_schema: ToolInputSchema; + cache_control?: CacheControlEphemeral | null; + /** Structured-outputs beta: enforce the schema as a strict grammar. */ + strict?: boolean; + /** Fine-grained tool streaming beta: stream tool input as it is generated. */ + eager_input_streaming?: boolean; +}; + +export type ToolChoiceAuto = { type: "auto"; disable_parallel_tool_use?: boolean }; +export type ToolChoiceAny = { type: "any"; disable_parallel_tool_use?: boolean }; +export type ToolChoiceTool = { type: "tool"; name: string; disable_parallel_tool_use?: boolean }; +export type ToolChoiceNone = { type: "none" }; + +export type ToolChoice = ToolChoiceAuto | ToolChoiceAny | ToolChoiceTool | ToolChoiceNone; + +// ─── Request ──────────────────────────────────────────────────────────────── + +export type Metadata = { user_id?: string | null }; + +export type ThinkingConfigEnabled = { + type: "enabled"; + budget_tokens: number; + /** Opus 4.7+ reasoning display mode. */ + display?: "summarized" | "omitted"; +}; + +export type ThinkingConfigDisabled = { type: "disabled" }; + +export type ThinkingConfigAdaptive = { + type: "adaptive"; + /** Opus 4.7+ reasoning display mode. */ + display?: "summarized" | "omitted"; +}; + +export type ThinkingConfigParam = ThinkingConfigEnabled | ThinkingConfigDisabled | ThinkingConfigAdaptive; + +export type OutputConfig = { + /** Adaptive-thinking effort level (effort beta). */ + effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; + /** Task-budgets beta. */ + task_budget?: TokenTaskBudget | null; +}; + +/** Claude Code context-management beta payload. */ +export type ContextManagement = { + edits: Array<{ type: "clear_thinking_20251015"; keep: "all" }>; +}; + +export type MessageCreateParams = { + model: string; + messages: MessageParam[]; + max_tokens: number; + system?: string | TextBlockParam[]; + temperature?: number; + top_p?: number; + top_k?: number; + stop_sequences?: string[]; + stream?: boolean; + tools?: Tool[]; + tool_choice?: ToolChoice; + metadata?: Metadata; + thinking?: ThinkingConfigParam; + output_config?: OutputConfig; + /** Fast-mode beta: realization of priority service tier. */ + speed?: "fast"; + /** Claude Code context-management beta. */ + context_management?: ContextManagement; +}; + +export type MessageCreateParamsStreaming = MessageCreateParams & { stream: true }; + +// ─── Response / usage ─────────────────────────────────────────────────────── + +export type StopReason = + | "end_turn" + | "max_tokens" + | "stop_sequence" + | "tool_use" + | "pause_turn" + | "refusal" + | "sensitive"; + +export type CacheCreation = { + ephemeral_5m_input_tokens?: number | null; + ephemeral_1h_input_tokens?: number | null; +}; + +export type ServerToolUsage = { + web_search_requests?: number | null; + web_fetch_requests?: number | null; +}; + +export type Usage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; + cache_creation?: CacheCreation | null; + server_tool_use?: ServerToolUsage | null; +}; + +/** The `message` envelope carried by `message_start`. */ +export type ResponseMessage = { + id: string; + type?: "message"; + role?: "assistant"; + model?: string; + content?: unknown[]; + stop_reason?: StopReason | null; + stop_sequence?: string | null; + usage: Usage; +}; + +// ─── Stream events ────────────────────────────────────────────────────────── + +/** `content_block` payload carried by `content_block_start`. */ +export type ResponseContentBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "redacted_thinking"; data: string } + | { type: "tool_use"; id: string; name: string; input?: Record | null }; + +export type ContentBlockDelta = + | { type: "text_delta"; text: string } + | { type: "input_json_delta"; partial_json: string } + | { type: "thinking_delta"; thinking: string } + | { type: "signature_delta"; signature: string }; + +export type StopDetails = { + type: string; + category?: string | null; + explanation?: string | null; +}; + +export type MessageDelta = { + stop_reason?: StopReason | null; + stop_sequence?: string | null; + stop_details?: StopDetails | null; +}; + +export type RawMessageStartEvent = { type: "message_start"; message: ResponseMessage }; +export type RawContentBlockStartEvent = { + type: "content_block_start"; + index: number; + content_block: ResponseContentBlock; +}; +export type RawContentBlockDeltaEvent = { type: "content_block_delta"; index: number; delta: ContentBlockDelta }; +export type RawContentBlockStopEvent = { type: "content_block_stop"; index: number }; +export type RawMessageDeltaEvent = { type: "message_delta"; delta: MessageDelta; usage: Usage }; +export type RawMessageStopEvent = { type: "message_stop" }; + +export type RawMessageStreamEvent = + | RawMessageStartEvent + | RawContentBlockStartEvent + | RawContentBlockDeltaEvent + | RawContentBlockStopEvent + | RawMessageDeltaEvent + | RawMessageStopEvent; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index de18d14c7..061fdf13a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,19 +2,10 @@ import * as nodeCrypto from "node:crypto"; import * as fs from "node:fs"; import { scheduler } from "node:timers/promises"; import * as tls from "node:tls"; -import Anthropic, { - APIConnectionTimeoutError as AnthropicConnectionTimeoutError, - type ClientOptions as AnthropicSdkClientOptions, -} from "@anthropic-ai/sdk"; -import type { - ContentBlockParam, - MessageCreateParamsStreaming, - MessageParam, - RawMessageStreamEvent, -} from "@anthropic-ai/sdk/resources/messages"; import { $env, extractHttpStatusFromError, + getInstallId, isEnoent, isRetryableError, isUnexpectedSocketCloseMessage, @@ -47,7 +38,6 @@ import type { StreamOptions, TextContent, ThinkingContent, - TokenTaskBudget, Tool, ToolCall, ToolResultMessage, @@ -74,6 +64,21 @@ import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; import { spillToDescription } from "../utils/schema/spill"; import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout"; import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug"; +import { + AnthropicConnectionTimeoutError, + type AnthropicFetchOptions, + AnthropicMessagesClient, + type AnthropicMessagesClientLike, +} from "./anthropic-client"; +import type { + ToolInputSchema as AnthropicToolInputSchema, + Tool as AnthropicWireTool, + ContentBlockParam, + MessageCreateParamsStreaming, + MessageParam, + RawMessageStreamEvent, + TextBlockParam, +} from "./anthropic-wire"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput, @@ -90,6 +95,8 @@ export type AnthropicHeaderOptions = { stream?: boolean; modelHeaders?: Record; isCloudflareAiGateway?: boolean; + claudeCodeSessionId?: string; + claudeCodeBetas?: readonly string[]; }; export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined { @@ -102,7 +109,7 @@ export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined } // Build deduplicated beta header string -export function buildBetaHeader(baseBetas: string[], extraBetas: string[]): string { +export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readonly string[]): string { const seen = new Set(); const result: string[] = []; for (const beta of [...baseBetas, ...extraBetas]) { @@ -115,16 +122,51 @@ export function buildBetaHeader(baseBetas: string[], extraBetas: string[]): stri return result.join(","); } -const claudeCodeBetaDefaults = [ - "claude-code-20250219", +const claudeCodeUtilityBetaDefaults = [ "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", -]; + "structured-outputs-2025-12-15", +] as const; +const claudeCodeAgentBetaDefaults = [ + "claude-code-20250219", + "oauth-2025-04-20", + "context-1m-2025-08-07", + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", + "mid-conversation-system-2026-04-07", + "advanced-tool-use-2025-11-20", +] as const; +const claudeCodeAgentPostEffortBetas = ["extended-cache-ttl-2025-04-11"] as const; const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14"; const interleavedThinkingBeta = "interleaved-thinking-2025-05-14"; +// Asks the API to redact thinking blocks from responses. Only sent when the +// caller explicitly hides thinking (`thinkingDisplay: "omitted"`); sending it +// by default suppresses the thinking traces callers expect to stream. +const redactThinkingBeta = "redact-thinking-2026-02-12"; const fastModeBeta = "fast-mode-2026-02-01"; const taskBudgetBeta = "task-budgets-2026-03-13"; +const effortBeta = "effort-2025-11-24"; + +function buildClaudeCodeBetas( + agentRequest: boolean, + thinkingRequest: boolean, + redactThinking: boolean, +): readonly string[] { + if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults; + const betas: string[] = []; + for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) { + betas.push(beta); + // Match CC's header order: redact-thinking immediately follows interleaved-thinking. + if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta); + } + if (!agentRequest) return betas; + if (thinkingRequest) betas.push(effortBeta); + betas.push(...claudeCodeAgentPostEffortBetas); + return betas; +} function getHeaderCaseInsensitive(headers: Record | undefined, headerName: string): string | undefined { if (!headers) return undefined; @@ -140,7 +182,7 @@ function isClaudeCodeClientUserAgent(userAgent: string | undefined): userAgent i return userAgent.toLowerCase().startsWith("claude-cli"); } -function isAnthropicApiBaseUrl(baseUrl?: string): boolean { +export function isAnthropicApiBaseUrl(baseUrl?: string): boolean { if (!baseUrl) return true; try { const url = new URL(baseUrl); @@ -163,8 +205,8 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record !enforcedHeaderKeys.has(key.toLowerCase())), ); @@ -191,6 +233,8 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record; -type AnthropicSamplingParams = MessageCreateParamsStreaming & { - top_p?: number; - top_k?: number; -}; - -type AnthropicOutputConfig = NonNullable & { - task_budget?: TokenTaskBudget | null; -}; +type AnthropicOutputConfig = NonNullable; function getAnthropicOutputConfig(params: MessageCreateParamsStreaming): AnthropicOutputConfig { - const outputConfig = (params.output_config ?? {}) as AnthropicOutputConfig; - params.output_config = outputConfig as typeof params.output_config; + const outputConfig = params.output_config ?? {}; + params.output_config = outputConfig; return outputConfig; } @@ -326,25 +363,16 @@ export function isAnthropicFastModeUnsupportedError(error: unknown): boolean { } function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean { - const tools = params.tools as Array<{ strict?: unknown }> | undefined; - return tools?.some(tool => tool.strict === true) ?? false; + return params.tools?.some(tool => tool.strict === true) ?? false; } -/** - * `speed` lives on `BetaMessageCreateParams` (client.beta.messages) but this - * provider posts via `client.messages.create`, whose param type doesn't - * include it. This alias narrows the cast to one place. - */ -type ParamsWithSpeed = MessageCreateParamsStreaming & { speed?: "fast" }; - function dropAnthropicFastMode(params: MessageCreateParamsStreaming): void { - delete (params as ParamsWithSpeed).speed; + delete params.speed; } function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void { - const tools = params.tools as Array<{ strict?: unknown }> | undefined; - if (!tools) return; - for (const tool of tools) { + if (!params.tools) return; + for (const tool of params.tools) { delete tool.strict; } } @@ -352,9 +380,10 @@ function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void { function getCacheControl( model: Model<"anthropic-messages">, baseUrl: string, - cacheRetention?: CacheRetention, + cacheRetention: CacheRetention | undefined, + isOAuthToken: boolean, ): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } { - const retention = resolveCacheRetention(cacheRetention); + const retention = cacheRetention ?? (isOAuthToken ? "long" : resolveCacheRetention(undefined)); if (retention === "none") { return { retention }; } @@ -368,10 +397,10 @@ function getCacheControl( }; } -// Stealth mode: Mimic Claude Code headers and tool prefixing. -export const claudeCodeVersion = "2.1.63"; +// Stealth mode: mimic Claude Code's request fingerprint. +export const claudeCodeVersion = "2.1.160"; export const claudeToolPrefix: string = "proxy_"; -export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +export const claudeCodeSystemInstruction = "You are Claude Code, Anthropic's official CLI for Claude."; export function mapStainlessOs(platform: string): "MacOS" | "Windows" | "Linux" | "FreeBSD" | `Other::${string}` { switch (platform.toLowerCase()) { @@ -409,13 +438,13 @@ export function mapStainlessArch(arch: string): "x64" | "arm64" | "x86" | `other export const claudeCodeHeaders = { "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Package-Version": "0.74.0", + "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Runtime": "node", "X-Stainless-Lang": "js", "X-Stainless-Arch": mapStainlessArch(process.arch), - "X-Stainless-Os": mapStainlessOs(process.platform), + "X-Stainless-OS": mapStainlessOs(process.platform), "X-Stainless-Timeout": "600", -} as const; +}; const enforcedHeaderKeys = new Set( [ @@ -431,21 +460,88 @@ const enforcedHeaderKeys = new Set( "X-App", "Authorization", "X-Api-Key", + "X-Claude-Code-Session-Id", + "x-client-request-id", "cf-aig-authorization", ].map(key => key.toLowerCase()), ); const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"; -function createClaudeBillingHeader(payload: unknown): string { - const payloadJson = JSON.stringify(payload) ?? ""; - const cch = nodeCrypto.createHash("sha256").update(payloadJson).digest("hex").slice(0, 5); - const randomBytes = new Uint8Array(2); - crypto.getRandomValues(randomBytes); - const buildHash = Array.from(randomBytes, byte => byte.toString(16).padStart(2, "0")) - .join("") +function createClaudeBillingHeader(firstUserMessageText: string): string { + // Fingerprint: SHA256(salt + msg[4] + msg[7] + msg[20] + version)[:3] + // Matches CC's computeFingerprint in utils/fingerprint.ts. + // Uses chars from the first user message (not the system prompt). + const k = [4, 7, 20].map(i => firstUserMessageText[i] ?? "0").join(""); + const versionSuffix = nodeCrypto + .createHash("sha256") + .update(`59cf53e54c78${k}${claudeCodeVersion}`) + .digest("hex") .slice(0, 3); - return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${buildHash}; cc_entrypoint=cli; cch=${cch};`; + // cch=00000: placeholder replaced with the real attestation hash by wrapFetchForCch + // before the request hits the wire (see below). + return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${versionSuffix}; cc_entrypoint=cli; ${CCH_PLACEHOLDER_STR};`; +} + +// cch attestation: XXHash64(body_with_placeholder, seed) low-20-bits, 5 hex chars. +const CCH_SEED = 0x4d659218e32a3268n; +const CCH_PLACEHOLDER_STR = "cch=00000"; +const cchEncoder = new TextEncoder(); +const CCH_PLACEHOLDER = cchEncoder.encode(CCH_PLACEHOLDER_STR); +// Combined anchor for the billing-header placeholder inside system[0]. +// "system":[{"type":"text","text":"x-anthropic-billing-header: +// Matches the exact JSON prefix of the first system block when +// createClaudeBillingHeader injects system[0]. "messages" serializes before +// "system" in Anthropic SDK payloads (~byte 29 vs ~byte 4705), so user content +// in the messages array can never match this sequence. User system prompt text +// lives in system[2] and therefore also cannot match. +const BILLING_SYSTEM_MARKER = cchEncoder.encode(`"system":[{"type":"text","text":"${CLAUDE_BILLING_HEADER_PREFIX}`); +const CCH_BILLING_SEARCH_WINDOW = 150; + +function patchCch(body: Uint8Array): Uint8Array { + // Find the combined system[0] + billing-header prefix marker. + let markerIdx = -1; + outer: for (let i = 0; i <= body.length - BILLING_SYSTEM_MARKER.length; i++) { + for (let j = 0; j < BILLING_SYSTEM_MARKER.length; j++) { + if (body[i + j] !== BILLING_SYSTEM_MARKER[j]) continue outer; + } + markerIdx = i; + break; + } + if (markerIdx === -1) return body; // no CC billing header injected + + // Scan at most CCH_BILLING_SEARCH_WINDOW bytes after the marker for the placeholder. + const searchFrom = markerIdx + BILLING_SYSTEM_MARKER.length; + const searchTo = Math.min(searchFrom + CCH_BILLING_SEARCH_WINDOW, body.length - CCH_PLACEHOLDER.length); + let idx = -1; + outer2: for (let i = searchFrom; i <= searchTo; i++) { + for (let j = 0; j < CCH_PLACEHOLDER.length; j++) { + if (body[i + j] !== CCH_PLACEHOLDER[j]) continue outer2; + } + idx = i; + break; + } + if (idx === -1) return body; // placeholder not within the billing header value + + // Hash the body with the placeholder in place (matches CC's in-place behaviour). + const h = Bun.hash.xxHash64(body, CCH_SEED); + const cch = (h & 0xfffffn).toString(16).padStart(5, "0"); + + for (let i = 0; i < 5; i++) body[idx + 4 + i] = cch.charCodeAt(i); + return body; +} + +type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; + +function wrapFetchForCch(base: FetchFn): FetchFn { + return (input, init) => { + if (init?.body && typeof init.body === "string" && init.body.includes(CCH_PLACEHOLDER_STR)) { + const encoded = cchEncoder.encode(init.body); + const patched = patchCch(encoded); + return base(input, { ...init, body: patched }); + } + return base(input, init); + }; } const CLAUDE_CLOAKING_USER_ID_REGEX = @@ -475,6 +571,23 @@ function isClaudeJsonUserId(userId: string): boolean { return typeof obj.session_id === "string" && obj.session_id.length > 0; } +function extractClaudeMetadataSessionId(userId: unknown): string | undefined { + if (typeof userId !== "string") return undefined; + if (isClaudeCloakingUserId(userId)) { + return userId.slice(userId.lastIndexOf("_session_") + "_session_".length); + } + if (userId.length === 0 || userId[0] !== "{") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(userId); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const sessionId = (parsed as Record).session_id; + return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined; +} + export function generateClaudeCloakingUserId(): string { const userHash = nodeCrypto.randomBytes(32).toString("hex"); const accountId = nodeCrypto.randomUUID().toLowerCase(); @@ -482,7 +595,21 @@ export function generateClaudeCloakingUserId(): string { return `user_${userHash}_account_${accountId}_session_${sessionId}`; } -function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean): string | undefined { +function deriveClaudeDeviceIdFromInstallId(): string { + return nodeCrypto.createHash("sha256").update(`omp-claude-device-id-v1:${getInstallId()}`).digest("hex"); +} +function generateClaudeJsonUserId(sessionId?: string): string { + return JSON.stringify({ + device_id: deriveClaudeDeviceIdFromInstallId(), + session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(), + }); +} + +function resolveAnthropicMetadataUserId( + userId: unknown, + isOAuthToken: boolean, + sessionId?: string, +): string | undefined { if (typeof userId === "string") { if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) { return userId; @@ -490,7 +617,7 @@ function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean): } if (!isOAuthToken) return undefined; - return generateClaudeCloakingUserId(); + return generateClaudeJsonUserId(sessionId); } const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]); export const applyClaudeToolPrefix = (name: string, prefixOverride: string = claudeToolPrefix) => { @@ -719,11 +846,11 @@ export interface AnthropicOptions extends StreamOptions { /** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */ isOAuth?: boolean; /** - * Pre-built Anthropic client instance. When provided, skips internal client - * construction entirely. Use this to inject alternative SDK clients such as - * `AnthropicVertex` that shares the same messaging API. + * Pre-built Anthropic Messages client. When provided, skips internal client + * construction entirely. Accepts any structurally compatible client, + * including SDK clients such as `AnthropicVertex`. */ - client?: Anthropic; + client?: AnthropicMessagesClientLike; } export type AnthropicClientOptionsArgs = { @@ -736,8 +863,11 @@ export type AnthropicClientOptionsArgs = { dynamicHeaders?: Record; isOAuth?: boolean; hasTools?: boolean; + thinkingEnabled?: boolean; + thinkingDisplay?: AnthropicThinkingDisplay; onSseEvent?: AnthropicOptions["onSseEvent"]; fetch?: FetchImpl; + claudeCodeSessionId?: string; }; export type AnthropicClientOptionsResult = { @@ -746,11 +876,9 @@ export type AnthropicClientOptionsResult = { authToken?: string | null; baseURL?: string; maxRetries: number; - dangerouslyAllowBrowser: boolean; defaultHeaders: Record; - logLevel: AnthropicSdkClientOptions["logLevel"]; - fetch?: AnthropicSdkClientOptions["fetch"]; - fetchOptions?: AnthropicSdkClientOptions["fetchOptions"]; + fetch?: FetchImpl; + fetchOptions?: AnthropicFetchOptions; }; const CLAUDE_CODE_TLS_CIPHERS = tls.DEFAULT_CIPHERS; @@ -796,10 +924,26 @@ function parseAnthropicCustomHeaders(rawHeaders: string | undefined): Record 0 ? parsed : undefined; } +/** + * Returns env-supplied custom headers (`ANTHROPIC_CUSTOM_HEADERS`) when they + * should be forwarded to the upstream endpoint. + * + * Foundry mode forwards them unconditionally. Outside Foundry, they're applied + * only when the configured base URL is a non-Anthropic host — i.e. an + * enterprise/corporate gateway that may require its own proprietary auth + * header. Stock `api.anthropic.com` would reject unknown headers, so they're + * omitted there. + */ +export function resolveAnthropicCustomHeadersForBaseUrl( + baseUrl: string | undefined, +): Record | undefined { + if (!isFoundryEnabled() && isAnthropicApiBaseUrl(baseUrl)) return undefined; + return parseAnthropicCustomHeaders($env.ANTHROPIC_CUSTOM_HEADERS); +} + function resolveAnthropicCustomHeaders(model: Model<"anthropic-messages">): Record | undefined { if (model.provider !== "anthropic") return undefined; - if (!isFoundryEnabled()) return undefined; - return parseAnthropicCustomHeaders($env.ANTHROPIC_CUSTOM_HEADERS); + return resolveAnthropicCustomHeadersForBaseUrl(model.baseUrl); } function looksLikeFilePath(value: string): boolean { @@ -851,7 +995,7 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl function buildClaudeCodeTlsFetchOptions( model: Model<"anthropic-messages">, baseUrl: string | undefined, -): AnthropicSdkClientOptions["fetchOptions"] | undefined { +): AnthropicFetchOptions | undefined { if (model.provider !== "anthropic") return undefined; if (!baseUrl) return undefined; @@ -885,10 +1029,6 @@ function mergeHeaders(...headerSources: (Record | undefined)[]): return merged; } -// The Anthropic SDK logs malformed SSE frames directly before rethrowing them. -// We surface the resulting provider error ourselves, so keep the SDK quiet. -const ANTHROPIC_SDK_LOG_LEVEL = "off" as const; - const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ "message_start", "message_delta", @@ -1148,7 +1288,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let activeAbortTracker = createAbortSourceTracker(options?.signal); try { - let client: Anthropic; + let client: AnthropicMessagesClientLike; let isOAuthToken: boolean; if (options?.client) { @@ -1165,6 +1305,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( if (options?.taskBudget && !extraBetas.includes(taskBudgetBeta)) { extraBetas.push(taskBudgetBeta); } + if (options?.thinkingEnabled && model.reasoning && !extraBetas.includes(effortBeta)) { + extraBetas.push(effortBeta); + } const created = createClient(model, { model, @@ -1176,8 +1319,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( dynamicHeaders: copilotDynamicHeaders?.headers, isOAuth: options?.isOAuth, hasTools: !!context.tools?.length, + thinkingEnabled: options?.thinkingEnabled, + thinkingDisplay: options?.thinkingDisplay, onSseEvent: options?.onSseEvent, fetch: options?.fetch, + claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id), }); client = created.client; isOAuthToken = created.isOAuthToken; @@ -1208,7 +1354,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( api: output.api, model: model.id, method: "POST", - url: `${baseUrl}/v1/messages`, + url: `${baseUrl}/v1/messages${isOAuthToken ? "?beta=true" : ""}`, body: nextParams, }; return nextParams; @@ -1237,7 +1383,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( activeAbortTracker = createAbortSourceTracker(options?.signal); const { requestSignal } = activeAbortTracker; const requestOptions = createSdkStreamRequestOptions(requestSignal, requestTimeoutMs); - const anthropicRequest = client.messages.create({ ...params, stream: true }, requestOptions); + const anthropicRequest: unknown = + isOAuthToken && client.beta + ? client.beta.messages.create({ ...params, stream: true }, requestOptions) + : client.messages.create({ ...params, stream: true }, requestOptions); let streamedReplayUnsafeContent = false; try { @@ -1352,7 +1501,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( name: isOAuthToken ? stripClaudeToolPrefix(event.content_block.name) : event.content_block.name, - arguments: (event.content_block.input as Record) ?? {}, + arguments: event.content_block.input ?? {}, partialJson: "", index: event.index, }; @@ -1445,7 +1594,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } } } else if (event.type === "message_delta") { - const rawStopReason = event.delta.stop_reason as string | null | undefined; + const rawStopReason = event.delta.stop_reason; if (rawStopReason) { output.stopReason = mapStopReason(rawStopReason); sawTerminalEnvelope = true; @@ -1625,50 +1774,64 @@ export type AnthropicSystemBlock = { type SystemBlockOptions = { includeClaudeCodeInstruction?: boolean; extraInstructions?: string[]; - billingPayload?: unknown; + /** Text of the first user message — used as fingerprint seed for the billing header. */ + firstUserMessageText?: string; cacheControl?: AnthropicCacheControl; }; +function withGlobalCacheScope(cacheControl: AnthropicCacheControl): AnthropicCacheControl { + return { ...cacheControl, scope: "global" }; +} + +function applyClaudeCodeSystemCache( + blocks: AnthropicSystemBlock[], + cacheControl: AnthropicCacheControl | undefined, +): number { + if (!cacheControl || blocks.length <= 2) return 0; + blocks[2] = { ...blocks[2], cache_control: withGlobalCacheScope(cacheControl) }; + if (blocks.length === 3) return 1; + const lastIndex = blocks.length - 1; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; + return 2; +} + export function buildAnthropicSystemBlocks( systemPrompt: readonly string[] | undefined, options: SystemBlockOptions = {}, ): AnthropicSystemBlock[] | undefined { - const { includeClaudeCodeInstruction = false, extraInstructions = [], billingPayload, cacheControl } = options; - const blocks: AnthropicSystemBlock[] = []; + const { includeClaudeCodeInstruction = false, extraInstructions = [], firstUserMessageText, cacheControl } = options; const sanitizedPrompts = normalizeSystemPrompts(systemPrompt); const trimmedInstructions = extraInstructions.map(instruction => instruction.trim()).filter(Boolean); const hasBillingHeader = sanitizedPrompts.some(prompt => prompt.includes(CLAUDE_BILLING_HEADER_PREFIX)); if (includeClaudeCodeInstruction && !hasBillingHeader) { - const payloadSeed = billingPayload ?? { - system: sanitizedPrompts, - extraInstructions: trimmedInstructions, - }; - blocks.push( - { type: "text", text: createClaudeBillingHeader(payloadSeed) }, - { - type: "text", - text: claudeCodeSystemInstruction, - }, - ); + const blocks: AnthropicSystemBlock[] = [ + { type: "text", text: createClaudeBillingHeader(firstUserMessageText ?? "") }, + { type: "text", text: claudeCodeSystemInstruction }, + ]; + + for (const instruction of trimmedInstructions) { + blocks.push({ type: "text", text: instruction }); + } + for (const prompt of sanitizedPrompts) { + blocks.push({ type: "text", text: prompt }); + } + applyClaudeCodeSystemCache(blocks, cacheControl); + + return blocks; } + const blocks: AnthropicSystemBlock[] = []; for (const instruction of trimmedInstructions) { blocks.push({ type: "text", text: instruction }); } - - for (const systemPrompt of sanitizedPrompts) { - blocks.push({ type: "text", text: systemPrompt }); + for (const prompt of sanitizedPrompts) { + blocks.push({ type: "text", text: prompt }); } - - // Attach cache_control to the LAST emitted block only. Anthropic breakpoints are cumulative - // prefix cuts, so a single trailing breakpoint covers every preceding block; spreading - // cache_control across N blocks wastes slots against the 4-breakpoint cap. const lastIndex = blocks.length - 1; if (cacheControl && lastIndex >= 0) { blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; } - return blocks.length > 0 ? blocks : undefined; } @@ -1688,8 +1851,11 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A headers, dynamicHeaders, hasTools = false, + thinkingEnabled = false, + thinkingDisplay, isOAuth, onSseEvent, + claudeCodeSessionId, } = args; const compat = getAnthropicCompat(model); const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinkingDisplay(model.id); @@ -1699,11 +1865,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A const foundryCustomHeaders = resolveAnthropicCustomHeaders(model); const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl); const baseFetch = args.fetch ?? fetch; - const debugFetch = onSseEvent - ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) - : args.fetch - ? baseFetch - : undefined; + // Only OAuth requests inject the CC billing header; no API-key request can ever + // contain it, so there is no need to install the rewriter for those. + const cchFetch = oauthToken ? wrapFetchForCch(baseFetch) : baseFetch; + const debugFetch = onSseEvent ? wrapFetchForSseDebug(cchFetch, event => onSseEvent(event, model)) : cchFetch; if (model.provider === "github-copilot") { const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken; const betaFeatures = [...extraBetas]; @@ -1728,10 +1893,8 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: copilotApiKey, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, - ...(debugFetch ? { fetch: debugFetch } : {}), + fetch: debugFetch, ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; } @@ -1752,6 +1915,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A stream, modelHeaders: mergeHeaders(model.headers, foundryCustomHeaders, headers, dynamicHeaders), isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway", + claudeCodeSessionId, + claudeCodeBetas: oauthToken + ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled, thinkingDisplay === "omitted") + : [], }); if (model.provider === "cloudflare-ai-gateway") { @@ -1761,10 +1928,23 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: null, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, + fetch: debugFetch, + }; + } + + // OpenCode's Anthropic-compatible gateway accepts bearer auth only; leaving + // apiKey set lets the SDK add X-Api-Key, which upstream Alibaba rejects. + if (model.provider === "opencode-go" || model.provider === "opencode-zen") { + return { + isOAuthToken: false, + apiKey: null, + authToken: null, + baseURL: baseUrl, + maxRetries: 5, + defaultHeaders, ...(debugFetch ? { fetch: debugFetch } : {}), + ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; } @@ -1774,10 +1954,8 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: oauthToken ? apiKey : undefined, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, - ...(debugFetch ? { fetch: debugFetch } : {}), + fetch: debugFetch, ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; } @@ -1785,9 +1963,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A function createClient( model: Model<"anthropic-messages">, args: AnthropicClientOptionsArgs, -): { client: Anthropic; isOAuthToken: boolean } { +): { client: AnthropicMessagesClient; isOAuthToken: boolean } { const { isOAuthToken: oauthToken, ...clientOptions } = buildAnthropicClientOptions({ ...args, model }); - const client = new Anthropic(clientOptions); + const client = new AnthropicMessagesClient(clientOptions); return { client, isOAuthToken: oauthToken }; } @@ -1860,66 +2038,36 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: const MAX_CACHE_BREAKPOINTS = 4; let cacheBreakpointsUsed = 0; - - if (params.tools && params.tools.length > 0) { - applyCacheControlToLastBlock(params.tools as Array, cacheControl); - cacheBreakpointsUsed++; - } - - if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return; + let isCCLayout = false; if (params.system && Array.isArray(params.system) && params.system.length > 0) { - applyCacheControlToLastBlock(params.system, cacheControl); - cacheBreakpointsUsed++; - } - - if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return; - - const userIndexes = params.messages - .map((message, index) => (message.role === "user" ? index : -1)) - .filter(index => index >= 0); - - if (userIndexes.length >= 2) { - const penultimateUserIndex = userIndexes[userIndexes.length - 2]; - const penultimateUser = params.messages[penultimateUserIndex]; - if (penultimateUser) { - if (typeof penultimateUser.content === "string") { - const contentBlock: ContentBlockParam & CacheControlBlock = { - type: "text", - text: penultimateUser.content, - cache_control: cacheControl, - }; - penultimateUser.content = [contentBlock]; - cacheBreakpointsUsed++; - } else if (Array.isArray(penultimateUser.content) && penultimateUser.content.length > 0) { - applyCacheControlToLastTextBlock( - penultimateUser.content as Array, - cacheControl, - ); - cacheBreakpointsUsed++; - } + isCCLayout = + params.system.length >= 3 && + (params.system[0] as { text?: string }).text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true; + if (isCCLayout) { + cacheBreakpointsUsed += applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl); + } else { + applyCacheControlToLastBlock(params.system, cacheControl); + cacheBreakpointsUsed++; } } if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return; - if (userIndexes.length >= 1) { - const lastUserIndex = userIndexes[userIndexes.length - 1]; - const lastUser = params.messages[lastUserIndex]; - if (lastUser) { - if (typeof lastUser.content === "string") { - const contentBlock: ContentBlockParam & CacheControlBlock = { - type: "text", - text: lastUser.content, - cache_control: cacheControl, - }; - lastUser.content = [contentBlock]; - } else if (Array.isArray(lastUser.content) && lastUser.content.length > 0) { - applyCacheControlToLastTextBlock( - lastUser.content as Array, - cacheControl, - ); - } + const start = isCCLayout ? Math.max(0, params.messages.length - 1) : Math.max(0, params.messages.length - 2); + for (let i = start; i < params.messages.length; i++) { + if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) break; + const message = params.messages[i]; + if (!message) continue; + if (typeof message.content === "string") { + message.content = [{ type: "text", text: message.content, cache_control: cacheControl }]; + cacheBreakpointsUsed++; + } else if (Array.isArray(message.content) && message.content.length > 0) { + applyCacheControlToLastTextBlock( + message.content as Array, + cacheControl, + ); + cacheBreakpointsUsed++; } } } @@ -1939,7 +2087,7 @@ function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void { const seenFiveMinute = { value: false }; if (params.tools) { - for (const tool of params.tools as Array) { + for (const tool of params.tools as Array) { normalizeCacheControlBlockTtl(tool, seenFiveMinute); } } @@ -2004,7 +2152,7 @@ function stripMessageCacheControl( function countCacheControlBreakpoints(params: MessageCreateParamsStreaming): number { let total = 0; if (params.tools) { - for (const tool of params.tools as Array) { + for (const tool of params.tools as Array) { if (tool.cache_control) total++; } } @@ -2030,7 +2178,7 @@ function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreak params.system && Array.isArray(params.system) ? (params.system as Array) : []; - const toolBlocks = (params.tools ?? []) as Array; + const toolBlocks = (params.tools ?? []) as Array; const lastSystemIndex = findLastCacheControlIndex(systemBlocks); const lastToolIndex = findLastCacheControlIndex(toolBlocks); if (systemBlocks.length > 0) { @@ -2051,6 +2199,55 @@ function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreak stripAllCacheControl(toolBlocks, excessCounter); } } + +function resolveAnthropicAdaptiveEffort( + model: Model<"anthropic-messages">, + options: AnthropicOptions, +): AnthropicEffort | undefined { + if (options.effort) return options.effort; + const requestedEffort = options.reasoning; + if (!requestedEffort) return undefined; + return mapEffortToAnthropicAdaptiveEffort(model, requestedEffort); +} + +function startsWithAfterAsciiWhitespace(value: string, prefix: string): boolean { + let index = 0; + while (index < value.length) { + const code = value.charCodeAt(index); + if (code !== 9 && code !== 10 && code !== 13 && code !== 32) break; + index++; + } + return value.startsWith(prefix, index); +} + +function isClaudeSyntheticUserText(value: string): boolean { + return startsWithAfterAsciiWhitespace(value, ""); +} + +function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): string { + for (const message of messages) { + if (message.role !== "user") continue; + const { content } = message; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + let fallback: string | undefined; + for (const block of content) { + if (block.type !== "text") continue; + fallback ??= block.text; + if (!isClaudeSyntheticUserText(block.text)) return block.text; + } + return fallback ?? ""; + } + return ""; +} + +function applyClaudeCodeContextManagement(params: MessageCreateParamsStreaming, isOAuthToken: boolean): void { + if (!isOAuthToken || params.thinking?.type !== "adaptive") return; + params.context_management = { + edits: [{ type: "clear_thinking_20251015", keep: "all" }], + }; +} + function buildParams( model: Model<"anthropic-messages">, baseUrl: string, @@ -2059,13 +2256,11 @@ function buildParams( options?: AnthropicOptions, disableStrictTools = false, ): MessageCreateParamsStreaming { - const { cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention); - const params: AnthropicSamplingParams = { + const { cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention, isOAuthToken); + const params: MessageCreateParamsStreaming = { model: model.id, - // `system`-role params (Opus 4.8 mid-conversation system messages) are not - // yet in the SDK's `MessageParam` union; cast until it widens. - messages: convertAnthropicMessages(context.messages, model, isOAuthToken) as MessageParam[], - max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0, + messages: convertAnthropicMessages(context.messages, model, isOAuthToken), + max_tokens: options?.maxTokens || model.maxTokens, stream: true, }; if (options?.temperature !== undefined && !options?.thinkingEnabled) { @@ -2105,29 +2300,26 @@ function buildParams( disableStrictTools || model.provider === "github-copilot", getAnthropicCompat(model).supportsEagerToolInputStreaming, ); + } else if (isOAuthToken) { + params.tools = []; } if (model.reasoning) { if (options?.thinkingEnabled) { const mode = model.thinking?.mode; - const requestedEffort = options.reasoning; - const effort = - options.effort ?? - (requestedEffort ? mapEffortToAnthropicAdaptiveEffort(model, requestedEffort) : undefined); + const effort = resolveAnthropicAdaptiveEffort(model, options); const compat = getAnthropicCompat(model); if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) { + const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" }; // Starting with Claude Opus 4.7, adaptive thinking content is omitted from the // response by default. Opt into summarized reasoning so thinking deltas keep // streaming with human-readable content for callers that rely on it. - const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" }; - if (supportsAdaptiveThinkingDisplay(model.id)) { + if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) { adaptive.display = options.thinkingDisplay ?? "summarized"; } - params.thinking = adaptive as typeof params.thinking; + params.thinking = adaptive; if (effort) { - // SDK's OutputConfig.effort type is not yet widened to include the new "xhigh" - // level introduced with Claude Opus 4.7. Cast until the SDK catches up. getAnthropicOutputConfig(params).effort = effort; } } else { @@ -2135,7 +2327,7 @@ function buildParams( type: "enabled", budget_tokens: options.thinkingBudgetTokens || 1024, display: options.thinkingDisplay ?? "summarized", - } as typeof params.thinking; + }; if (mode === "anthropic-budget-effort" && effort) { getAnthropicOutputConfig(params).effort = effort; } @@ -2148,23 +2340,20 @@ function buildParams( if (options?.taskBudget) { getAnthropicOutputConfig(params).task_budget = options.taskBudget; } - const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken); + const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId); if (metadataUserId) { params.metadata = { user_id: metadataUserId }; } if (resolveServiceTier(options?.serviceTier, model.provider) === "priority") { - (params as ParamsWithSpeed).speed = "fast"; + params.speed = "fast"; } if (options?.toolChoice) { if (typeof options.toolChoice === "string") { params.tool_choice = { type: options.toolChoice }; } else if (isOAuthToken && options.toolChoice.name) { - params.tool_choice = { - ...options.toolChoice, - name: applyClaudeToolPrefix(options.toolChoice.name), - }; + params.tool_choice = { ...options.toolChoice, name: applyClaudeToolPrefix(options.toolChoice.name) }; } else { params.tool_choice = options.toolChoice; } @@ -2186,21 +2375,18 @@ function buildParams( } const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku"); - const billingSystemPrompts = normalizeSystemPrompts(context.systemPrompt); - const billingPayload = shouldInjectClaudeCodeInstruction - ? { - ...params, - ...(billingSystemPrompts.length > 0 ? { system: billingSystemPrompts } : {}), - } - : undefined; + const firstUserMessageText = shouldInjectClaudeCodeInstruction + ? extractClaudeCodeFirstUserMessageText(context.messages) + : ""; const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, { includeClaudeCodeInstruction: shouldInjectClaudeCodeInstruction, - billingPayload, + firstUserMessageText, }); if (systemBlocks) { params.system = systemBlocks; } disableThinkingIfToolChoiceForced(params); + applyClaudeCodeContextManagement(params, isOAuthToken); ensureMaxTokensForThinking(params, model); applyPromptCaching(params, cacheControl); enforceCacheControlLimit(params, 4); @@ -2262,12 +2448,10 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul } /** - * Anthropic message param extended with the mid-conversation `system` role - * (Opus 4.8+). The SDK's `MessageParam` predates the feature and only allows - * `user`/`assistant`, so the system variant is modeled locally and cast back - * to `MessageParam[]` at the call site. + * A single Anthropic conversation turn, including the mid-conversation + * `system` role (Opus 4.8+). */ -export type AnthropicMessageParam = MessageParam | { role: "system"; content: MessageParam["content"] }; +export type AnthropicMessageParam = MessageParam; export function convertAnthropicMessages( messages: Message[], @@ -2649,8 +2833,6 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { return result; } -type AnthropicToolInputSchema = Anthropic.Messages.Tool["input_schema"]; - type AnthropicToolSchemaPlan = { inputSchema: AnthropicToolInputSchema; strict: boolean; @@ -2870,23 +3052,26 @@ function convertTools( isOAuthToken: boolean, disableStrictTools = false, supportsEagerToolInputStreaming = true, -): Anthropic.Messages.Tool[] { +): AnthropicWireTool[] { if (!tools) return []; const schemaPlans = buildAnthropicToolSchemaPlans(tools, disableStrictTools); return tools.map((tool, index) => { const plan = schemaPlans[index]; - return { + const baseTool = { name: isOAuthToken ? applyClaudeToolPrefix(tool.name) : tool.name, description: tool.description || "", input_schema: plan.inputSchema, + }; + return { + ...baseTool, ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), ...(plan.strict ? { strict: true } : {}), }; }); } -function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason { +function mapStopReason(reason: string): StopReason { switch (reason) { case "end_turn": return "stop"; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 73d41b3d2..2bc805931 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -2247,7 +2247,7 @@ function findLastUserMessageIndex(messages: Message[]): number { * actual model prompt. `turns[]` is UI/display metadata. Without populating * this field, multi-turn conversations lose prior context — the model sees * only an empty placeholder where historical user turns should be. - * The last user message is excluded because it is sent in the action. + * The active user message is excluded because it is sent in the action. */ /** * Build one Cursor system-message JSON blob per ordered system prompt. Emitting separate blobs @@ -2270,17 +2270,16 @@ function buildRootPromptMessagesJson( messages: Message[], systemPromptIds: Uint8Array[], blobStore: Map, + activeUserMessageIndex = findLastUserMessageIndex(messages), ): Uint8Array[] { const entries: Uint8Array[] = [...systemPromptIds]; - const lastUserIdx = findLastUserMessageIndex(messages); - const pushJson = (obj: unknown) => { const bytes = new TextEncoder().encode(JSON.stringify(obj)); entries.push(storeCursorBlob(blobStore, bytes)); }; for (let i = 0; i < messages.length; i++) { - if (i === lastUserIdx) break; + if (i === activeUserMessageIndex) break; const msg = messages[i]; if (msg.role === "user" || msg.role === "developer") { const content = buildCursorRootPromptContent(msg.content); @@ -2306,12 +2305,16 @@ function buildRootPromptMessagesJson( /** * Convert context.messages to Cursor's ConversationTurnStructure blob IDs. * Groups messages into turns: each turn is a user message followed by the assistant's response. - * Excludes the last user message (which goes in the action). + * Excludes the active user message (which goes in the action). * * Each `AgentConversationTurnStructure.user_message`, `steps[]`, and the outer * `ConversationStateStructure.turns[]` entry is a blob ID into `blobStore`. */ -function buildConversationTurns(messages: Message[], blobStore: Map): Uint8Array[] { +function buildConversationTurns( + messages: Message[], + blobStore: Map, + activeUserMessageIndex = findLastUserMessageIndex(messages), +): Uint8Array[] { const turns: Uint8Array[] = []; // Find turn boundaries - each turn starts with a user message @@ -2325,15 +2328,10 @@ function buildConversationTurns(messages: Message[], blobStore: Map(); - const rootPromptMessagesJson = buildRootPromptMessagesJson(messages, [], blobStore).map(blobId => - JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))), + const rootPromptMessagesJson = buildRootPromptMessagesJson(messages, [], blobStore, activeUserMessageIndex).map( + blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))), ); const turnUserMessagesJson: JsonValue[] = []; - for (const turnBlobId of buildConversationTurns(messages, blobStore)) { + const turnStepMessagesJson: JsonValue[][] = []; + for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex)) { const turn = fromBinary(ConversationTurnStructureSchema, readCursorBlob(blobStore, turnBlobId)); if (turn.turn.case !== "agentConversationTurn") { continue; } const userMessage = fromBinary(UserMessageSchema, readCursorBlob(blobStore, turn.turn.value.userMessage)); turnUserMessagesJson.push(toJson(UserMessageSchema, userMessage)); + turnStepMessagesJson.push( + turn.turn.value.steps.map(stepBlobId => { + const step = fromBinary(ConversationStepSchema, readCursorBlob(blobStore, stepBlobId)); + return toJson(ConversationStepSchema, step); + }), + ); } - return { rootPromptMessagesJson, turnUserMessagesJson }; + return { rootPromptMessagesJson, turnUserMessagesJson, turnStepMessagesJson }; } function createCursorUserMessage( content: string | (TextContent | ImageContent)[], @@ -2479,12 +2488,15 @@ function buildGrpcRequest( storeCursorBlob(blobStore, new TextEncoder().encode(json)), ); - const lastMessage = context.messages[context.messages.length - 1]; + const activeUserMessageIndex = context.messages.length - 1; + const activeMessage = context.messages[activeUserMessageIndex]; + const activeUserMessage = + activeMessage?.role === "user" || activeMessage?.role === "developer" ? activeMessage : undefined; let userContent: string | (TextContent | ImageContent)[] | undefined; let userText = ""; let hasUserImages = false; - if (lastMessage?.role === "user" || lastMessage?.role === "developer") { - userContent = lastMessage.content; + if (activeUserMessage?.role === "user" || activeUserMessage?.role === "developer") { + userContent = activeUserMessage.content; if (typeof userContent === "string") { userText = userContent.trim(); } else { @@ -2508,15 +2520,20 @@ function buildGrpcRequest( }, }); - // Build conversation turns from prior messages (excluding the last user message). - // This populates the UI-side history view (`turns[]`). - const turns = buildConversationTurns(context.messages, blobStore); + // Build conversation turns from prior messages, excluding only the active user message + // when the request is sending one. Resume actions must preserve trailing tool results. + const turns = buildConversationTurns(context.messages, blobStore, activeUserMessage ? activeUserMessageIndex : -1); // Build `rootPromptMessagesJson` from prior messages. Cursor's server uses this // field (not `turns[]`) to construct the actual model prompt; if we only send the // system prompt here, multi-turn conversations lose prior context and the model // sees only the current user message. - const rootPromptMessagesJson = buildRootPromptMessagesJson(context.messages, systemPromptIds, blobStore); + const rootPromptMessagesJson = buildRootPromptMessagesJson( + context.messages, + systemPromptIds, + blobStore, + activeUserMessage ? activeUserMessageIndex : -1, + ); // Preserve cached non-history state fields (todos, file states, summaries, etc.) // when the system prompt is unchanged; otherwise start fresh. diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index ed33369ca..9a04f449f 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -2,8 +2,8 @@ * Lazy provider module loading. * * Each provider module is loaded only when its stream function is first called. - * This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk, - * openai) at startup. The loaded module promise is cached so subsequent calls + * This avoids eagerly importing heavy SDK dependencies (e.g., openai) at + * startup. The loaded module promise is cached so subsequent calls * reuse the same import. * * NOTE: stream.ts currently imports providers directly, so this file is not yet diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 2aa4531e1..b49fc2868 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -163,6 +163,8 @@ export interface UsageFetchContext { export interface UsageProvider { id: Provider; fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise; + /** Parse provider rate-limit response headers (lowercased keys) into a usage report, if supported. */ + parseRateLimitHeaders?(headers: Record, now?: number): UsageReport | null; supports?(params: UsageFetchParams): boolean; } diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 8ae269d6b..b9e54c38a 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -22,9 +22,9 @@ const CLAUDE_HEADERS = { accept: "application/json, text/plain, */*", "accept-encoding": "gzip, compress, deflate, br", "anthropic-beta": - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", "content-type": "application/json", - "user-agent": "claude-cli/2.1.63 (external, cli)", + "user-agent": "claude-cli/2.1.160 (external, cli)", connection: "keep-alive", } as const; @@ -57,6 +57,7 @@ interface ParsedUsageBucket { utilization?: number; resetsAt?: number; } +type ClaudeUnifiedWindow = "5h" | "7d"; interface ClaudeUsageResponse { five_hour?: ClaudeUsageBucket | null; @@ -85,6 +86,20 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined { } return { utilization, resetsAt }; } +function parseUnifiedWindow( + headers: Record, + window: ClaudeUnifiedWindow, +): ParsedUsageBucket | undefined { + const prefix = `anthropic-ratelimit-unified-${window}-`; + const utilizationFraction = toNumber(headers[`${prefix}utilization`]); + const resetSeconds = toNumber(headers[`${prefix}reset`]); + const utilization = utilizationFraction === undefined ? undefined : utilizationFraction * 100; + const resetsAt = resetSeconds !== undefined && resetSeconds > 0 ? resetSeconds * 1000 : undefined; + if (utilization === undefined && resetsAt === undefined) { + return undefined; + } + return { utilization, resetsAt }; +} function getPayloadString(payload: Record, key: string): string | undefined { const value = payload[key]; @@ -325,6 +340,41 @@ function buildUsageLimit(args: { }; } +export function parseClaudeRateLimitHeaders(headers: Record, now = Date.now()): UsageReport | null { + const fiveHour = parseUnifiedWindow(headers, "5h"); + const sevenDay = parseUnifiedWindow(headers, "7d"); + const limits = [ + buildUsageLimit({ + id: "anthropic:5h", + label: "Claude 5 Hour", + windowId: "5h", + windowLabel: "5 Hour", + durationMs: FIVE_HOURS_MS, + bucket: fiveHour, + provider: "anthropic", + shared: true, + }), + buildUsageLimit({ + id: "anthropic:7d", + label: "Claude 7 Day", + windowId: "7d", + windowLabel: "7 Day", + durationMs: SEVEN_DAYS_MS, + bucket: sevenDay, + provider: "anthropic", + shared: true, + }), + ].filter((limit): limit is UsageLimit => limit !== null); + + if (limits.length === 0) return null; + return { + provider: "anthropic", + fetchedAt: now, + limits, + metadata: { source: "ratelimit-headers" }, + }; +} + async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { if (params.provider !== "anthropic") return null; const credential = params.credential; @@ -418,6 +468,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext export const claudeUsageProvider: UsageProvider = { id: "anthropic", fetchUsage: fetchClaudeUsage, + parseRateLimitHeaders: parseClaudeRateLimitHeaders, supports: params => params.provider === "anthropic" && params.credential.type === "oauth", }; diff --git a/packages/ai/src/utils/anthropic-auth.ts b/packages/ai/src/utils/anthropic-auth.ts index 280b3e4db..7d40b8a68 100644 --- a/packages/ai/src/utils/anthropic-auth.ts +++ b/packages/ai/src/utils/anthropic-auth.ts @@ -12,6 +12,7 @@ import { $env } from "@oh-my-pi/pi-utils"; import { buildAnthropicHeaders as buildProviderAnthropicHeaders, normalizeAnthropicBaseUrl, + resolveAnthropicCustomHeadersForBaseUrl, } from "../providers/anthropic"; import { isFoundryEnabled } from "./foundry"; @@ -66,6 +67,10 @@ export function buildAnthropicAuthConfig(apiKey: string, baseUrl?: string): Anth /** * Builds HTTP headers for Anthropic API requests (search variant). + * + * Forwards `ANTHROPIC_CUSTOM_HEADERS` when the resolver deems them applicable + * (Foundry mode, or a non-Anthropic base URL — typically an enterprise + * gateway), matching the streaming path so web search behaves identically. */ export function buildAnthropicSearchHeaders(auth: AnthropicAuthConfig): Record { return buildProviderAnthropicHeaders({ @@ -74,6 +79,7 @@ export function buildAnthropicSearchHeaders(auth: AnthropicAuthConfig): Record atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"; +const BOOTSTRAP_URL = "https://api.anthropic.com/api/claude_cli/bootstrap"; +const CLAUDE_CODE_BOOTSTRAP_MODEL = "claude-opus-4-8"; +const CLAUDE_CODE_BOOTSTRAP_USER_AGENT = "claude-code/2.1.160"; const CALLBACK_PORT = 54545; const CALLBACK_PATH = "/callback"; -const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; +// Scopes required for direct OAuth-token inference (user:inference) plus account/session management. +// platform.claude.com/oauth/authorize issues console tokens (org:create_api_key only) and does not +// grant user:inference — the claude.ai endpoint is required for direct inference access. +const SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; function formatErrorDetails(error: unknown): string { if (error instanceof Error) { @@ -30,12 +37,17 @@ function formatErrorDetails(error: unknown): string { return String(error); } -async function postJson(url: string, body: Record): Promise { +async function postJson( + url: string, + body: Record, + extraHeaders?: Record, +): Promise { const response = await fetch(url, { method: "POST", headers: { + // No Accept header: CC omits it on OAuth token requests. + ...extraHeaders, "Content-Type": "application/json", - Accept: "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), @@ -51,9 +63,8 @@ async function postJson(url: string, body: Record): Pro /** * Decoded shape of Anthropic's `/v1/oauth/token` response (both * `authorization_code` exchange and `refresh_token` refresh return the same - * envelope). The `account` block is inlined alongside the tokens, so we can - * surface `accountId` / `email` on {@link OAuthCredentials} without a separate - * `/api/oauth/profile` round-trip. + * envelope). Newer responses inline `account`; older/stale credentials can + * recover the same identity from `/api/claude_cli/bootstrap`. */ interface AnthropicTokenResponse { access_token: string; @@ -62,6 +73,13 @@ interface AnthropicTokenResponse { account?: { uuid?: string; email_address?: string }; } +interface AnthropicBootstrapResponse { + oauth_account?: { + account_uuid?: string; + account_email?: string; + }; +} + function parseOAuthTokenResponse(responseBody: string, operation: string): AnthropicTokenResponse { try { return JSON.parse(responseBody) as AnthropicTokenResponse; @@ -91,6 +109,53 @@ function extractAccountFromTokenResponse(data: AnthropicTokenResponse): { }; } +async function fetchBootstrapIdentity(accessToken: string): Promise<{ accountId?: string; email?: string }> { + const url = `${BOOTSTRAP_URL}?entrypoint=cli&model=${encodeURIComponent(CLAUDE_CODE_BOOTSTRAP_MODEL)}`; + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": CLAUDE_CODE_BOOTSTRAP_USER_AGENT, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: AbortSignal.timeout(30_000), + }); + const responseBody = await response.text(); + if (!response.ok) { + throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`); + } + let data: AnthropicBootstrapResponse; + try { + data = JSON.parse(responseBody) as AnthropicBootstrapResponse; + } catch (error) { + throw new Error( + `Anthropic bootstrap returned invalid JSON. url=${url}; body=${responseBody}; details=${formatErrorDetails(error)}`, + ); + } + const accountUuid = data.oauth_account?.account_uuid; + const accountEmail = data.oauth_account?.account_email; + return { + accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, + email: typeof accountEmail === "string" && accountEmail.length > 0 ? accountEmail : undefined, + }; +} + +async function resolveAccountIdentity(data: AnthropicTokenResponse): Promise<{ accountId?: string; email?: string }> { + const identity = extractAccountFromTokenResponse(data); + if (identity.accountId && identity.email) return identity; + try { + const bootstrap = await fetchBootstrapIdentity(data.access_token); + return { + accountId: identity.accountId ?? bootstrap.accountId, + email: identity.email ?? bootstrap.email, + }; + } catch { + return identity; + } +} + export class AnthropicOAuthFlow extends OAuthCallbackFlow { #verifier: string = ""; #challenge: string = ""; @@ -152,7 +217,7 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow { } const tokenData = parseOAuthTokenResponse(responseBody, "token exchange"); - const { accountId, email } = extractAccountFromTokenResponse(tokenData); + const { accountId, email } = await resolveAccountIdentity(tokenData); return { refresh: tokenData.refresh_token, @@ -178,17 +243,25 @@ export async function loginAnthropic(ctrl: OAuthController): Promise { let responseBody: string; try { - responseBody = await postJson(TOKEN_URL, { - grant_type: "refresh_token", - client_id: CLIENT_ID, - refresh_token: refreshToken, - }); + responseBody = await postJson( + TOKEN_URL, + { + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, + }, + { + // CC sends these on refresh but not on the initial code exchange + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "anthropic-sdk-typescript/0.94.0 userOAuthProvider", + }, + ); } catch (error) { throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`); } const data = parseOAuthTokenResponse(responseBody, "token refresh"); - const { accountId, email } = extractAccountFromTokenResponse(data); + const { accountId, email } = await resolveAccountIdentity(data); return { refresh: data.refresh_token || refreshToken, diff --git a/packages/ai/src/utils/oauth/xiaomi.ts b/packages/ai/src/utils/oauth/xiaomi.ts index aa95f051a..0779eaca8 100644 --- a/packages/ai/src/utils/oauth/xiaomi.ts +++ b/packages/ai/src/utils/oauth/xiaomi.ts @@ -18,6 +18,7 @@ const STANDARD_AUTH_URL = "https://platform.xiaomimimo.com/#/console/api-keys"; const STANDARD_API_BASE_URL = "https://api.xiaomimimo.com/v1"; const TOKEN_PLAN_SGP_API_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; const TOKEN_PLAN_AMS_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1"; +const TOKEN_PLAN_CN_API_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"; const TOKEN_PLAN_KEY_PREFIX = "tp-"; const STANDARD_VALIDATION_MODEL = "mimo-v2-flash"; const TOKEN_PLAN_VALIDATION_MODEL = "mimo-v2.5"; @@ -29,12 +30,13 @@ function isTokenPlanKey(apiKey: string): boolean { const VALIDATION_TIMEOUT_MS = 15_000; async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promise { - // For token-plan keys try SGP first, then AMS as fallback. + // For token-plan keys try SGP → AMS → CN in order until one succeeds. // Standard sk- keys only hit the one endpoint. const endpoints = isTokenPlanKey(apiKey) ? [ { baseUrl: TOKEN_PLAN_SGP_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }, { baseUrl: TOKEN_PLAN_AMS_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }, + { baseUrl: TOKEN_PLAN_CN_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }, ] : [{ baseUrl: STANDARD_API_BASE_URL, model: STANDARD_VALIDATION_MODEL }]; diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 7f21a4d2a..7f82626ec 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -839,14 +839,19 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn if (typeof currentValue !== "string") continue; const result = tryParseJsonForTypes(currentValue, issue.expectedTypes); - if (!result.changed) continue; + const coercedValue = result.changed + ? result.value + : issue.expectedTypes.includes("array") + ? [currentValue] + : undefined; + if (coercedValue === undefined) continue; if (!owned) { nextArgs = structuredCloneJSON(nextArgs); owned = true; changed = true; } - nextArgs = setValueAtPointer(nextArgs, issue.instancePath, result.value); + nextArgs = setValueAtPointer(nextArgs, issue.instancePath, coercedValue); } return { value: changed ? nextArgs : args, changed }; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 329e776be..2ea2ce33e 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -9,6 +9,7 @@ import { buildAnthropicClientOptions, buildAnthropicHeaders, buildAnthropicSystemBlocks, + claudeCodeSystemInstruction, claudeCodeVersion, generateClaudeCloakingUserId, isClaudeCloakingUserId, @@ -59,6 +60,8 @@ type CaptureAnthropicOptions = { topK?: number; taskBudget?: TokenTaskBudget; toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; + thinkingDisplay?: "summarized" | "omitted"; + sessionId?: string; }; function captureAnthropicPayload( @@ -79,11 +82,37 @@ function captureAnthropicPayload( topK: options?.topK, taskBudget: options?.taskBudget, toolChoice: options?.toolChoice, + thinkingDisplay: options?.thinkingDisplay, + sessionId: options?.sessionId, onPayload: payload => resolve(payload), }); return promise; } +function expectClaudeMetadataUserId(userId: string | undefined, expectedSessionId?: string): void { + expect(typeof userId).toBe("string"); + const parsed = JSON.parse(userId ?? "{}") as { + device_id?: unknown; + account_uuid?: unknown; + session_id?: unknown; + }; + expect(typeof parsed.device_id).toBe("string"); + if (typeof parsed.device_id === "string") { + expect(parsed.device_id).toMatch(/^[0-9a-f]{64}$/); + } + if (parsed.account_uuid !== undefined) { + expect(parsed.account_uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + } + if (expectedSessionId) { + expect(parsed.session_id).toBe(expectedSessionId); + } else { + expect(typeof parsed.session_id).toBe("string"); + if (typeof parsed.session_id === "string") { + expect(parsed.session_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + } + } +} + describe("Anthropic request fingerprint alignment", () => { it("maps Stainless OS and arch values from explicit inputs", () => { expect(mapStainlessOs("darwin")).toBe("MacOS"); @@ -107,22 +136,88 @@ describe("Anthropic request fingerprint alignment", () => { stream: true, }); - expect(headers["X-Stainless-Os"]).toBe(mapStainlessOs(process.platform)); + expect(headers["X-Stainless-OS"]).toBe(mapStainlessOs(process.platform)); expect(headers["X-Stainless-Arch"]).toBe(mapStainlessArch(process.arch)); }); - it("attaches cache_control only to the last emitted system block when cacheControl is set", () => { + it("matches Claude Code OAuth header defaults", () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-oat-test", + isOAuth: true, + stream: true, + claudeCodeSessionId: sessionId, + }); + + expect(headers.Accept).toBe("application/json"); + expect(headers["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`); + expect(headers["X-Claude-Code-Session-Id"]).toBe(sessionId); + expect(headers["x-client-request-id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(headers["Anthropic-Beta"]).toBe( + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", + ); + }); + + it("matches Claude Code utility OAuth beta defaults when tools and thinking are absent", () => { + const options = buildAnthropicClientOptions({ + model: ANTHROPIC_MODEL, + apiKey: "sk-ant-oat-test", + stream: true, + interleavedThinking: true, + hasTools: false, + thinkingEnabled: false, + }); + + expect(options.defaultHeaders["Anthropic-Beta"]).toBe( + "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15", + ); + }); + + it("sends redact-thinking beta only when thinking display is omitted", () => { + const baseArgs = { + model: ANTHROPIC_MODEL, + apiKey: "sk-ant-oat-test", + stream: true, + interleavedThinking: true, + hasTools: true, + thinkingEnabled: true, + } as const; + + const visible = buildAnthropicClientOptions(baseArgs); + expect(visible.defaultHeaders["Anthropic-Beta"]).not.toContain("redact-thinking-2026-02-12"); + + const hidden = buildAnthropicClientOptions({ ...baseArgs, thinkingDisplay: "omitted" }); + expect(hidden.defaultHeaders["Anthropic-Beta"]).toBe( + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", + ); + + const hiddenUtility = buildAnthropicClientOptions({ + ...baseArgs, + hasTools: false, + thinkingEnabled: false, + thinkingDisplay: "omitted", + }); + expect(hiddenUtility.defaultHeaders["Anthropic-Beta"]).toBe( + "oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15", + ); + }); + + it("matches CC system-block layout: billing and instruction uncached, context cached in order", () => { const blocks = buildAnthropicSystemBlocks(["Stay concise."], { includeClaudeCodeInstruction: true, extraInstructions: ["Use citations when possible"], cacheControl: { type: "ephemeral" }, }); - expect(blocks).toBeDefined(); - // Earlier blocks must NOT carry cache_control; a single trailing breakpoint covers them all. + expect(blocks).toHaveLength(4); + expect(blocks?.[0].text).toStartWith("x-anthropic-billing-header:"); + expect(blocks?.[0].cache_control).toBeUndefined(); + expect(blocks?.[1].text).toBe(claudeCodeSystemInstruction); + expect(blocks?.[1].cache_control).toBeUndefined(); expect(blocks?.[2]).toEqual({ type: "text", text: "Use citations when possible", + cache_control: { type: "ephemeral", scope: "global" }, }); expect(blocks?.[3]).toEqual({ type: "text", @@ -131,6 +226,58 @@ describe("Anthropic request fingerprint alignment", () => { }); }); + it("caches Claude Code context and the last user block in OAuth request payloads", async () => { + const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + })) as { + system?: Array<{ text?: string; cache_control?: unknown }>; + messages?: Array<{ content?: Array<{ cache_control?: unknown }> | string }>; + }; + + expect(payload.system?.[0]?.text).toStartWith("x-anthropic-billing-header:"); + expect(payload.system?.[0]?.cache_control).toBeUndefined(); + expect(payload.system?.[1]?.text).toBe(claudeCodeSystemInstruction); + expect(payload.system?.[1]?.cache_control).toBeUndefined(); + expect(payload.system?.[2]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h", scope: "global" }); + const content = payload.messages?.[0]?.content; + expect(Array.isArray(content)).toBe(true); + expect(Array.isArray(content) ? content[0]?.cache_control : undefined).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + + it("billing-header fingerprint uses first user message, not leading developer message", async () => { + const userText = "Hello from user with enough chars padding here"; + + // Conversation with only a user message. + const payloadUserOnly = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Be helpful."], + messages: [{ role: "user", content: userText, timestamp: Date.now() }], + })) as { system?: Array<{ type: string; text?: string }> }; + + // Conversation prefixed with a developer message before the same user message. + const payloadWithDev = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Be helpful."], + messages: [ + { role: "developer", content: "developer instruction text", timestamp: Date.now() }, + { role: "user", content: userText, timestamp: Date.now() }, + ], + })) as { system?: Array<{ type: string; text?: string }> }; + + const billingUserOnly = payloadUserOnly.system?.[0].text ?? ""; + const billingWithDev = payloadWithDev.system?.[0].text ?? ""; + + // Both payloads must carry a billing header. + expect(billingUserOnly).toStartWith("x-anthropic-billing-header:"); + expect(billingWithDev).toStartWith("x-anthropic-billing-header:"); + + // The cc_version suffix (fingerprint) must be identical — developer message must not affect it. + const extractSuffix = (header: string) => header.match(/cc_version=[^.]+\.([a-f0-9]{3})/)?.[1]; + expect(extractSuffix(billingWithDev)).toBe(extractSuffix(billingUserOnly)); + }); + it("places the automatic Anthropic cache breakpoint on the last ordered system prompt", async () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, @@ -219,14 +366,49 @@ describe("Anthropic request fingerprint alignment", () => { expect(isClaudeCloakingUserId(userId)).toBe(true); }); - it("injects generated metadata.user_id for OAuth requests when missing", async () => { + it("injects Claude Code JSON metadata.user_id for OAuth requests when missing", async () => { const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], })) as { metadata?: { user_id?: string } }; - const userId = payload.metadata?.user_id; - expect(typeof userId).toBe("string"); - expect(isClaudeCloakingUserId(userId ?? "")).toBe(true); + expectClaudeMetadataUserId(payload.metadata?.user_id); + }); + + it("derives generated OAuth device_id deterministically across sessions", async () => { + const first = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { sessionId: "167ec5b4-e711-4169-879f-84fa52679d9c" }, + )) as { metadata?: { user_id?: string } }; + const second = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi again", timestamp: Date.now() }], + }, + { sessionId: "abcdefab-cdef-abcd-efab-cdefabcdef12" }, + )) as { metadata?: { user_id?: string } }; + const firstUserId = JSON.parse(first.metadata?.user_id ?? "{}") as { device_id?: string }; + const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { device_id?: string }; + + expect(firstUserId.device_id).toBe(secondUserId.device_id); + }); + + it("uses the explicit session id for generated OAuth metadata", async () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const payload = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { sessionId }, + )) as { metadata?: { user_id?: string } }; + + expectClaudeMetadataUserId(payload.metadata?.user_id, sessionId); }); it("does not inject metadata.user_id for non-OAuth requests without caller metadata", async () => { @@ -255,6 +437,55 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.metadata?.user_id).toBe(userId); }); + it("mirrors JSON metadata session_id into the Claude Code session header", async () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const userId = JSON.stringify({ + device_id: "a".repeat(64), + account_uuid: "12345678-1234-1234-1234-1234567890ab", + session_id: sessionId, + }); + const { promise, resolve } = Promise.withResolvers<{ + sessionHeader: string | null; + url: string; + accept: string | null; + }>(); + const controller = new AbortController(); + const fakeFetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + resolve({ + sessionHeader: headers.get("X-Claude-Code-Session-Id"), + url: input instanceof Request ? input.url : String(input), + accept: headers.get("Accept"), + }); + controller.abort(); + return new Response('event: message_stop\ndata: {"type":"message_stop"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + streamAnthropic( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "sk-ant-oat-test", + isOAuth: true, + metadata: { user_id: userId }, + signal: controller.signal, + fetch: fakeFetch, + }, + ); + + expect(await promise).toEqual({ + sessionHeader: sessionId, + url: "https://api.anthropic.com/v1/messages?beta=true", + accept: "application/json", + }); + }); + it("preserves real Claude Code JSON-format metadata.user_id for OAuth requests", async () => { // Matches the shape produced by services/api/claude.ts → getAPIMetadata in // the Claude Code source: { device_id, account_uuid, session_id, ...extra }. @@ -301,7 +532,7 @@ describe("Anthropic request fingerprint alignment", () => { )) as { metadata?: { user_id?: string } }; expect(payload.metadata?.user_id).not.toBe(userId); - expect(isClaudeCloakingUserId(payload.metadata?.user_id ?? "")).toBe(true); + expectClaudeMetadataUserId(payload.metadata?.user_id); }); it("replaces invalid caller metadata.user_id for OAuth requests", async () => { @@ -315,7 +546,7 @@ describe("Anthropic request fingerprint alignment", () => { )) as { metadata?: { user_id?: string } }; expect(payload.metadata?.user_id).not.toBe("invalid-user-id"); - expect(isClaudeCloakingUserId(payload.metadata?.user_id ?? "")).toBe(true); + expectClaudeMetadataUserId(payload.metadata?.user_id); }); it("adds additionalProperties false to Anthropic tool object schemas", async () => { const originalNestedSchema = { @@ -544,6 +775,34 @@ describe("Anthropic request fingerprint alignment", () => { expect(block).not.toHaveProperty("minItems"); }); + it("keeps OAuth tool names behind the proxy prefix with eager streaming and strict flags", async () => { + const tools: Tool[] = [ + { + name: "bash", + description: "run commands", + strict: true, + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + } as TJsonSchema, + }, + ]; + + const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + tools, + })) as { + tools?: Array<{ name?: string; strict?: boolean; eager_input_streaming?: boolean; cache_control?: unknown }>; + }; + + expect(payload.tools?.[0]?.name).toBe("proxy_bash"); + expect(payload.tools?.[0]?.strict).toBe(true); + expect(payload.tools?.[0]?.eager_input_streaming).toBe(true); + expect(payload.tools?.[0]?.cache_control).toBeUndefined(); + }); + it("marks only the Anthropic strict allowlist strict", async () => { const tools: Tool[] = [ ...(["bash", "python", "edit", "find"] as const).map(name => ({ @@ -868,6 +1127,56 @@ describe("Anthropic request fingerprint alignment", () => { ); }); + it("forwards ANTHROPIC_CUSTOM_HEADERS to an enterprise gateway base URL without Foundry mode", async () => { + const gatewayModel: Model<"anthropic-messages"> = { + ...ANTHROPIC_MODEL, + baseUrl: "https://gateway.example.com", + }; + await withEnv( + { + CLAUDE_CODE_USE_FOUNDRY: undefined, + FOUNDRY_BASE_URL: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_CUSTOM_HEADERS: "X-Gateway-Key: secret", + }, + () => { + const options = buildAnthropicClientOptions({ + model: gatewayModel, + apiKey: "sk-ant-api-test", + extraBetas: [], + stream: true, + interleavedThinking: false, + dynamicHeaders: {}, + }); + + expect(options.defaultHeaders["X-Gateway-Key"]).toBe("secret"); + }, + ); + }); + + it("omits ANTHROPIC_CUSTOM_HEADERS when neither Foundry mode nor a custom base URL is configured", async () => { + await withEnv( + { + CLAUDE_CODE_USE_FOUNDRY: undefined, + FOUNDRY_BASE_URL: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_CUSTOM_HEADERS: "X-Gateway-Key: secret", + }, + () => { + const options = buildAnthropicClientOptions({ + model: ANTHROPIC_MODEL, + apiKey: "sk-ant-api-test", + extraBetas: [], + stream: true, + interleavedThinking: false, + dynamicHeaders: {}, + }); + + expect(options.defaultHeaders["X-Gateway-Key"]).toBeUndefined(); + }, + ); + }); + it("loads Foundry mTLS and CA material from file paths", async () => { const tmpDir = path.join(os.tmpdir(), `pi-ai-foundry-${Date.now()}-${Math.random().toString(16).slice(2)}`); fs.mkdirSync(tmpDir, { recursive: true }); @@ -1013,7 +1322,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.thinking).toBeUndefined(); }); - it("drops sampling params and requests summarized adaptive thinking for Opus 4.7", async () => { + it("drops sampling params and keeps summarized adaptive thinking for OAuth Opus 4.7+", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, @@ -1041,6 +1350,7 @@ describe("Anthropic request fingerprint alignment", () => { top_p?: number; top_k?: number; thinking?: { type?: string; display?: string }; + context_management?: { edits?: Array<{ type?: string; keep?: string | number }> }; output_config?: { effort?: string }; }; @@ -1048,6 +1358,67 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.top_p).toBeUndefined(); expect(payload.top_k).toBeUndefined(); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.context_management).toEqual({ + edits: [{ type: "clear_thinking_20251015", keep: "all" }], + }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + + const maxPayload = (await captureAnthropicPayload( + { + ...ANTHROPIC_MODEL, + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + thinking: { + mode: "anthropic-adaptive", + minLevel: Effort.Minimal, + maxLevel: Effort.XHigh, + }, + }, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + thinkingEnabled: true, + reasoning: Effort.XHigh, + }, + )) as { + thinking?: { type?: string; display?: string }; + output_config?: { effort?: string }; + }; + expect(maxPayload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(maxPayload.output_config).toEqual({ effort: "max" }); + }); + + it("keeps summarized adaptive thinking by default for API-key Opus 4.7+ requests", async () => { + const payload = (await captureAnthropicPayload( + { + ...ANTHROPIC_MODEL, + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + thinking: { + mode: "anthropic-adaptive", + minLevel: Effort.Minimal, + maxLevel: Effort.XHigh, + }, + }, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + isOAuth: false, + thinkingEnabled: true, + reasoning: Effort.High, + }, + )) as { + thinking?: { type?: string; display?: string }; + context_management?: unknown; + output_config?: { effort?: string }; + }; + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.context_management).toBeUndefined(); expect(payload.output_config).toEqual({ effort: "xhigh" }); }); @@ -1146,3 +1517,60 @@ describe("Anthropic request fingerprint alignment", () => { expect(stripClaudeToolPrefix("proxy_Read", "proxy_")).toBe("Read"); }); }); + +describe("cch attestation", () => { + it("wrapFetchForCch: replaces cch=00000 with correct XXHash64 in outgoing request body", async () => { + const { promise: bodyPromise, resolve: bodyResolve } = Promise.withResolvers(); + const controller = new AbortController(); + + const fakeFetch = async (_input: string | URL | Request, init?: RequestInit): Promise => { + const raw = init?.body; + const body = typeof raw === "string" ? raw : new TextDecoder().decode(raw as Uint8Array); + bodyResolve(body); + controller.abort(); + return new Response('event: message_stop\ndata: {"type":"message_stop"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + streamAnthropic( + ANTHROPIC_MODEL, + { + systemPrompt: ["Be helpful."], + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }, + { apiKey: "sk-ant-oat-test", isOAuth: true, signal: controller.signal, fetch: fakeFetch }, + ); + + const capturedBody = await bodyPromise; + + // The placeholder must have been replaced before the request was sent. + expect(capturedBody).toContain("cch="); + expect(capturedBody).not.toContain("cch=00000"); + const m = capturedBody.match(/cch=([0-9a-f]{5})/); + expect(m).not.toBeNull(); + + // Self-consistency: hashing the body with the placeholder restored must reproduce the embedded cch. + const CCH_SEED = 0x4d659218e32a3268n; + const withPlaceholder = capturedBody.replace(/cch=[0-9a-f]{5}/, "cch=00000"); + const h = Bun.hash.xxHash64(new TextEncoder().encode(withPlaceholder), CCH_SEED); + expect(m![1]).toBe((h & 0xfffffn).toString(16).padStart(5, "0")); + }); + + it("derives cch from low-20-bits of XXHash64(body, seed) — external reference values", () => { + // Each body contains "cch=00000" as the Bun HTTP layer sees it before patching. + // Expected low-20-bit hashes precomputed with the Python xxhash reference. + const CCH_SEED = 0x4d659218e32a3268n; + const enc = new TextEncoder(); + const cases: [string, string][] = [ + ["cch=00000", "a47f7"], + ['{"messages":[],"cch=00000","x":1}', "3073d"], + ["x-anthropic-billing-header: cc_version=2.1.158; cc_entrypoint=cli; cch=00000;", "f2b0b"], + ]; + for (const [body, expected] of cases) { + const h = Bun.hash.xxHash64(enc.encode(body), CCH_SEED); + expect((h & 0xfffffn).toString(16).padStart(5, "0")).toBe(expected); + } + }); +}); diff --git a/packages/ai/test/anthropic-client.test.ts b/packages/ai/test/anthropic-client.test.ts new file mode 100644 index 000000000..64eb086f3 --- /dev/null +++ b/packages/ai/test/anthropic-client.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "bun:test"; +import { + AnthropicApiError, + AnthropicConnectionTimeoutError, + AnthropicMessagesClient, +} from "../src/providers/anthropic-client"; +import type { MessageCreateParamsStreaming } from "../src/providers/anthropic-wire"; + +const params: MessageCreateParamsStreaming = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + max_tokens: 64, + stream: true, +}; + +type FetchCall = { url: string; init: RequestInit }; + +function createFetchMock(responses: Array): { calls: FetchCall[]; fetch: typeof fetch } { + const calls: FetchCall[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(input), init: init ?? {} }); + const next = responses[Math.min(calls.length - 1, responses.length - 1)]; + if (next instanceof Error) throw next; + return next.clone(); + }) as typeof fetch; + return { calls, fetch: fetchImpl }; +} + +const anthropicErrorBody = JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "The compiled grammar is too large." }, +}); + +describe("AnthropicMessagesClient error mapping", () => { + it("maps non-2xx responses to AnthropicApiError with status and body in message", async () => { + const { calls, fetch } = createFetchMock([ + new Response(anthropicErrorBody, { status: 400, headers: { "request-id": "req_err" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", baseURL: "https://api.anthropic.com", fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .then( + () => undefined, + err => err, + ); + + expect(error).toBeInstanceOf(AnthropicApiError); + const apiError = error as AnthropicApiError; + // Downstream classification reads `.status` (extractHttpStatusFromError) and + // regex-matches the message body (isAnthropicStrictGrammarTooLargeError). + expect(apiError.status).toBe(400); + expect(apiError.message).toStartWith("400 "); + expect(apiError.message).toContain("invalid_request_error"); + expect(apiError.message).toContain("compiled grammar is too large"); + expect(apiError.requestId).toBe("req_err"); + // 400 is not retryable: exactly one attempt. + expect(calls.length).toBe(1); + }); + + it("does not invent a body when the error response is empty", async () => { + const { fetch } = createFetchMock([new Response(null, { status: 500 })]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 0, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect((error as AnthropicApiError).message).toBe("500 status code (no body)"); + }); +}); + +describe("AnthropicMessagesClient retries", () => { + it("retries 429 honoring retry-after-ms and succeeds", async () => { + const { calls, fetch } = createFetchMock([ + new Response("overloaded", { status: 429, headers: { "retry-after-ms": "1" } }), + new Response("{}", { status: 200 }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 2, fetch }); + + const response = await client.messages.create(params).asResponse(); + + expect(response.status).toBe(200); + expect(calls.length).toBe(2); + }); + + it("obeys x-should-retry: false over a retryable status", async () => { + const { calls, fetch } = createFetchMock([ + new Response("stop", { status: 503, headers: { "x-should-retry": "false" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 3, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect((error as AnthropicApiError).status).toBe(503); + expect(calls.length).toBe(1); + }); + + it("surfaces the final error after exhausting the retry budget", async () => { + const { calls, fetch } = createFetchMock([ + new Response("err", { status: 500, headers: { "retry-after-ms": "1" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 2, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect(calls.length).toBe(3); // initial attempt + 2 retries + }); +}); + +describe("AnthropicMessagesClient timeout and abort", () => { + it("throws AnthropicConnectionTimeoutError when no response arrives in time", async () => { + const hangingFetch = ((_input: string | URL | Request, init?: RequestInit) => { + const { promise, reject } = Promise.withResolvers(); + init?.signal?.addEventListener("abort", () => reject(new Error("aborted by signal")), { once: true }); + return promise; + }) as typeof fetch; + const client = new AnthropicMessagesClient({ apiKey: "sk-test", fetch: hangingFetch }); + + const error = await client.messages + .create(params, { timeout: 5, maxRetries: 0 }) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicConnectionTimeoutError); + // isRetryableError() keys off "timed out"/"timeout" phrasing. + expect((error as Error).message).toMatch(/timed out/i); + }); + + it("maps caller aborts to 'Request was aborted.' without retrying", async () => { + const controller = new AbortController(); + const { calls, fetch } = createFetchMock([new Error("network down")]); + const abortingFetch = ((input: string | URL | Request, init?: RequestInit) => { + controller.abort(); + return fetch(input, init); + }) as typeof fetch; + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 5, fetch: abortingFetch }); + + const error = await client.messages + .create(params, { signal: controller.signal }) + .asResponse() + .catch(err => err); + + expect((error as Error).message).toBe("Request was aborted."); + expect(calls.length).toBe(1); + }); +}); + +describe("AnthropicMessagesClient request assembly", () => { + it("sends auth, body, and beta URL according to client options", async () => { + const { calls, fetch } = createFetchMock([new Response("{}", { status: 200 })]); + const client = new AnthropicMessagesClient({ + authToken: "oauth-token", + baseURL: "https://api.anthropic.com", + defaultHeaders: { "Anthropic-Version": "2023-06-01" }, + fetch, + }); + + await client.beta.messages.create(params).asResponse(); + + expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages?beta=true"); + expect(calls[0].init.method).toBe("POST"); + const headers = calls[0].init.headers as Record; + expect(headers.Authorization).toBe("Bearer oauth-token"); + expect(headers["Anthropic-Version"]).toBe("2023-06-01"); + expect(JSON.parse(String(calls[0].init.body))).toEqual(params); + }); + + it("never overrides auth headers already present in defaultHeaders", async () => { + const { calls, fetch } = createFetchMock([new Response("{}", { status: 200 })]); + const client = new AnthropicMessagesClient({ + apiKey: "sk-wrong", + authToken: "wrong-token", + defaultHeaders: { "X-Api-Key": "sk-right", authorization: "Bearer right-token" }, + fetch, + }); + + await client.messages.create(params).asResponse(); + + const headers = calls[0].init.headers as Record; + expect(headers["X-Api-Key"]).toBe("sk-right"); + expect(headers.authorization).toBe("Bearer right-token"); + expect(headers.Authorization).toBeUndefined(); + expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages"); + }); +}); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 2af3f501f..cf232f2e9 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { buildAnthropicAuthConfig, buildAnthropicUrl } from "../src/utils/anthropic-auth"; +import { buildAnthropicAuthConfig, buildAnthropicSearchHeaders, buildAnthropicUrl } from "../src/utils/anthropic-auth"; import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/utils/oauth/anthropic"; import { withEnv } from "./helpers"; @@ -21,7 +21,7 @@ describe("anthropic oauth alignment", () => { expect(authUrl.origin + authUrl.pathname).toBe("https://claude.ai/oauth/authorize"); expect(authUrl.searchParams.get("scope")).toBe( - "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", ); expect(authUrl.searchParams.get("state")).toBe(state); expect(authUrl.searchParams.get("redirect_uri")).toBe(redirectUri); @@ -37,6 +37,10 @@ describe("anthropic oauth alignment", () => { access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600, + account: { + uuid: "11111111-2222-3333-4444-555555555555", + email_address: "user@example.com", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -64,6 +68,10 @@ describe("anthropic oauth alignment", () => { access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600, + account: { + uuid: "11111111-2222-3333-4444-555555555555", + email_address: "user@example.com", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -87,6 +95,10 @@ describe("anthropic oauth alignment", () => { access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600, + account: { + uuid: "11111111-2222-3333-4444-555555555555", + email_address: "user@example.com", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -99,15 +111,22 @@ describe("anthropic oauth alignment", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("uses api.anthropic.com token URL for refresh", async () => { + it("uses api.anthropic.com token URL and CC headers for refresh", async () => { const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { expect(typeof input === "string" ? input : input.toString()).toBe("https://api.anthropic.com/v1/oauth/token"); expect(init?.method).toBe("POST"); + const headers = init?.headers as Record | undefined; + expect(headers?.["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(headers?.["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider"); return new Response( JSON.stringify({ access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 7200, + account: { + uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email_address: "refreshed@example.com", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -170,13 +189,31 @@ describe("anthropic oauth alignment", () => { expect(result.email).toBe("refreshed@example.com"); }); - it("leaves accountId/email undefined when token response omits account block", async () => { - const fetchMock = vi.fn(async () => { + it("fetches bootstrap identity when token response omits account block", async () => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.anthropic.com/v1/oauth/token") { + return new Response( + JSON.stringify({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + expect(url).toBe("https://api.anthropic.com/api/claude_cli/bootstrap?entrypoint=cli&model=claude-opus-4-8"); + expect(init?.method).toBe("GET"); + const headers = init?.headers as Record | undefined; + expect(headers?.Authorization).toBe("Bearer access-token"); + expect(headers?.["User-Agent"]).toBe("claude-code/2.1.160"); + expect(headers?.["anthropic-beta"]).toBe("oauth-2025-04-20"); return new Response( JSON.stringify({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_in: 3600, + oauth_account: { + account_uuid: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + account_email: "bootstrap@example.com", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -187,8 +224,38 @@ describe("anthropic oauth alignment", () => { await flow.generateAuthUrl("state-noaccount", "http://localhost:54545/callback"); const result = await flow.exchangeToken("code-noaccount", "state-noaccount", "http://localhost:54545/callback"); + expect(result.accountId).toBe("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); + expect(result.email).toBe("bootstrap@example.com"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("leaves accountId/email undefined when token and bootstrap responses omit identity", async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.anthropic.com/v1/oauth/token") { + return new Response( + JSON.stringify({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ client_data: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const flow = new AnthropicOAuthFlow({}); + await flow.generateAuthUrl("state-noaccount", "http://localhost:54545/callback"); + const result = await flow.exchangeToken("code-noaccount", "state-noaccount", "http://localhost:54545/callback"); + expect(result.accountId).toBeUndefined(); expect(result.email).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); @@ -261,3 +328,66 @@ describe("buildAnthropicAuthConfig", () => { ); }); }); + +describe("buildAnthropicSearchHeaders", () => { + it("forwards ANTHROPIC_CUSTOM_HEADERS when the base URL is an enterprise gateway", async () => { + await withEnv( + { + CLAUDE_CODE_USE_FOUNDRY: undefined, + FOUNDRY_BASE_URL: undefined, + ANTHROPIC_BASE_URL: "https://gateway.example.com", + ANTHROPIC_CUSTOM_HEADERS: "X-Gateway-Key: secret, X-Route: search", + }, + () => { + const auth = buildAnthropicAuthConfig("sk-ant-api-key"); + expect(auth.baseUrl).toBe("https://gateway.example.com"); + const headers = buildAnthropicSearchHeaders(auth); + expect(headers["X-Gateway-Key"]).toBe("secret"); + expect(headers["X-Route"]).toBe("search"); + // Non-Anthropic base URL uses Bearer auth, not X-Api-Key. + expect(headers.Authorization).toBe("Bearer sk-ant-api-key"); + expect(headers["X-Api-Key"]).toBeUndefined(); + }, + ); + }); + + it("omits ANTHROPIC_CUSTOM_HEADERS when targeting api.anthropic.com without Foundry", async () => { + await withEnv( + { + CLAUDE_CODE_USE_FOUNDRY: undefined, + FOUNDRY_BASE_URL: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_CUSTOM_HEADERS: "X-Gateway-Key: secret", + }, + () => { + const auth = buildAnthropicAuthConfig("sk-ant-api-key"); + expect(auth.baseUrl).toBe("https://api.anthropic.com"); + const headers = buildAnthropicSearchHeaders(auth); + expect(headers["X-Gateway-Key"]).toBeUndefined(); + expect(headers["X-Api-Key"]).toBe("sk-ant-api-key"); + }, + ); + }); + + it("forwards ANTHROPIC_CUSTOM_HEADERS in Foundry mode even on an Anthropic-shaped base URL", async () => { + await withEnv( + { + CLAUDE_CODE_USE_FOUNDRY: "true", + FOUNDRY_BASE_URL: undefined, + ANTHROPIC_BASE_URL: undefined, + ANTHROPIC_CUSTOM_HEADERS: "user-id: alice", + }, + () => { + const auth = buildAnthropicAuthConfig("sk-ant-api-key", "https://api.anthropic.com"); + const headers = buildAnthropicSearchHeaders(auth); + expect(headers["user-id"]).toBe("alice"); + }, + ); + }); + + it("includes the web-search beta in Anthropic-Beta", () => { + const auth = buildAnthropicAuthConfig("sk-ant-api-key"); + const headers = buildAnthropicSearchHeaders(auth); + expect(headers["Anthropic-Beta"]).toContain("web-search-2025-03-05"); + }); +}); diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f082044fb..dfe5d5c50 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { Messages } from "@anthropic-ai/sdk/resources/messages/messages"; import { streamAnthropic } from "../src/providers/anthropic"; +import { AnthropicMessages } from "../src/providers/anthropic-client"; import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; const model: Model<"anthropic-messages"> = { @@ -209,7 +209,7 @@ afterEach(() => { describe("anthropic stream envelope handling", () => { it("ignores duplicate message_start envelopes without resetting streamed text", async () => { - vi.spyOn(Messages.prototype, "create").mockImplementation( + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => createMockRequest(createTextSuccessEvents("hello", { duplicateMessageStart: true })) as never, ); @@ -231,7 +231,7 @@ describe("anthropic stream envelope handling", () => { it("ignores ping before message_start and streams the response once", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest(createTextSuccessEventsWithPreamble("hello", [{ type: "ping" }])) as never; }); @@ -256,7 +256,7 @@ describe("anthropic stream envelope handling", () => { it("ignores unknown preamble events before message_start and streams the response once", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest( createTextSuccessEventsWithPreamble("hello", [{ type: "custom_preamble_event", trace_id: "trace_123" }]), @@ -283,7 +283,7 @@ describe("anthropic stream envelope handling", () => { it("retries malformed envelopes before content starts without duplicating streamed text events", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest( attempt === 1 ? createMalformedPreMessageStartEvents() : createTextSuccessEvents("recovered"), @@ -322,7 +322,7 @@ describe("anthropic stream envelope handling", () => { const providerSessionState = new Map(); const strictFlags: boolean[][] = []; let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { attempt += 1; strictFlags.push(getStrictFlags(params)); if (attempt === 1) { @@ -378,7 +378,7 @@ describe("anthropic stream envelope handling", () => { const providerSessionState = new Map(); const strictFlags: boolean[][] = []; let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { attempt += 1; strictFlags.push(getStrictFlags(params)); return createRejectedMockRequest(createOtherInvalidRequestError()) as never; @@ -405,7 +405,7 @@ describe("anthropic stream envelope handling", () => { it("does not retry malformed envelopes after partial tool-call content starts streaming", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest(createMalformedToolUseEvents()) as never; }); @@ -434,7 +434,7 @@ describe("anthropic stream envelope handling", () => { expect("partialJson" in toolCall).toBe(false); }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { - vi.spyOn(Messages.prototype, "create").mockImplementation( + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => createRawSseRequest( createTextSuccessSseFrames("hello", [ @@ -460,7 +460,9 @@ describe("anthropic stream envelope handling", () => { const incompleteFrames = createTextSuccessSseFrames("partial").filter( frame => !frame.includes("event: message_stop"), ); - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(incompleteFrames) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => createRawSseRequest(incompleteFrames) as never, + ); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; @@ -488,7 +490,7 @@ describe("anthropic stream envelope handling", () => { sseFrame("message_delta", successEvents[4]), sseFrame("message_stop", { type: "message_stop" }), ]; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { @@ -520,7 +522,9 @@ describe("anthropic stream envelope handling", () => { }, { type: "message_stop" }, ]; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => createMockRequest(refusalEvents) as never, + ); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; @@ -548,7 +552,7 @@ describe("anthropic stream envelope handling", () => { ], }; const payloads: unknown[] = []; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { payloads.push(params); return createMockRequest(createTextSuccessEvents("ok")) as never; }); @@ -577,7 +581,7 @@ describe("anthropic stream envelope handling", () => { it("emits 1h cache TTL only for canonical Anthropic API with compatible long-cache support", async () => { const payloads: unknown[] = []; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { payloads.push(params); return createMockRequest(createTextSuccessEvents("ok")) as never; }); diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index d0225ff90..0b14384b4 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import type Anthropic from "@anthropic-ai/sdk"; import { streamAnthropic } from "../src/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; import type { Context, Model } from "../src/types"; import { waitForDelayOrAbort } from "./helpers"; @@ -155,8 +155,8 @@ describe("anthropic first-event timeout retries", () => { signal: requestOptions?.signal, events: attempt === 1 ? undefined : createSuccessfulAnthropicEvents("retry recovered"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { @@ -188,8 +188,8 @@ describe("anthropic first-event timeout retries", () => { connectDelayMs: 2, events: createSuccessfulAnthropicEvents("delayed connect"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const result = await streamAnthropic(model, context, { client, @@ -218,8 +218,8 @@ describe("anthropic first-event timeout retries", () => { connectDelayMs: 20, events: createSuccessfulAnthropicEvents("too late"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { @@ -240,8 +240,8 @@ describe("anthropic first-event timeout retries", () => { const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => { attempt += 1; return createAnthropicMockStream({ signal: requestOptions?.signal }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const controller = new AbortController(); setTimeout(() => controller.abort(), 1); @@ -289,8 +289,8 @@ describe("anthropic first-event timeout retries", () => { ], hangAfterEvents: true, }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index 15bff0d2d..fcb2d87a6 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -17,13 +17,25 @@ import { AuthStorage, type StoredAuthCredential, } from "../src/auth-storage"; -import type { UsageReport } from "../src/usage"; +import type { UsageLimit, UsageReport } from "../src/usage"; import * as claudeUsage from "../src/usage/claude"; function anthropicReports(reports: UsageReport[] | null): UsageReport[] { return (reports ?? []).filter(r => r.provider === "anthropic"); } +function requireAnthropicReport(reports: UsageReport[] | null): UsageReport { + const report = anthropicReports(reports)[0]; + if (!report) throw new Error("expected anthropic usage report"); + return report; +} + +function requireLimit(report: UsageReport, id: string): UsageLimit { + const limit = report.limits.find(candidate => candidate.id === id); + if (!limit) throw new Error(`expected ${id} limit`); + return limit; +} + /** * Force every cache entry to look stale to AuthStorage WITHOUT dropping the * value. The cache layer is two-tier: the store-level `expiresAtSec` controls @@ -120,6 +132,53 @@ function makeReport(account: string): UsageReport { }; } +function makeTieredReport(account: string): UsageReport { + return { + provider: "anthropic", + fetchedAt: Date.now() - 10_000, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h", shared: true }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: 42, limit: 100, usedFraction: 0.42, unit: "percent" }, + status: "ok", + }, + { + id: "anthropic:7d", + label: "Claude 7 Day", + scope: { provider: "anthropic", windowId: "7d", shared: true }, + window: { id: "7d", label: "7 Day" }, + amount: { used: 84, limit: 100, usedFraction: 0.84, unit: "percent" }, + status: "ok", + }, + { + id: "anthropic:7d:opus", + label: "Claude 7 Day (Opus)", + scope: { provider: "anthropic", windowId: "7d", tier: "opus" }, + window: { id: "7d", label: "7 Day" }, + amount: { used: 12, limit: 100, usedFraction: 0.12, unit: "percent" }, + status: "ok", + }, + ], + metadata: { + email: account, + accountId: `account-${account}`, + endpoint: "https://api.anthropic.com/api/oauth/usage", + }, + }; +} + +function usageHeaders(fiveHour: string, sevenDay: string): Record { + return { + "anthropic-ratelimit-unified-5h-utilization": fiveHour, + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-7d-utilization": sevenDay, + "anthropic-ratelimit-unified-7d-reset": "1780531200", + }; +} + describe("AuthStorage usage cache: last-good failure fallback", () => { let store: ObservableStore; let storage: AuthStorage; @@ -273,6 +332,76 @@ describe("AuthStorage usage cache: jitter", () => { }); }); +describe("AuthStorage usage cache: header ingestion", () => { + let store: ObservableStore; + let storage: AuthStorage; + + beforeEach(async () => { + store = makeStore([oauthRow(1, "a@example.com")]); + storage = new AuthStorage(store, { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }); + await storage.reload(); + }); + + afterEach(() => { + storage.close(); + vi.restoreAllMocks(); + }); + + it("writes the same per-credential cache key that fetchUsageReports reads", async () => { + let calls = 0; + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { + calls += 1; + throw new Error("usage endpoint should not be probed after header ingestion"); + }); + + expect(await storage.getApiKey("anthropic", "s")).toBe("oat-1"); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.02", "0.3"), { sessionId: "s" })).toBe(true); + + const report = requireAnthropicReport(await storage.fetchUsageReports()); + expect(calls).toBe(0); + expect(report.metadata?.source).toBe("ratelimit-headers"); + expect(requireLimit(report, "anthropic:5h").amount.used).toBe(2); + expect(requireLimit(report, "anthropic:7d").amount.used).toBe(30); + }); + + it("throttles repeated header ingestion for the same credential cache key", async () => { + expect(await storage.getApiKey("anthropic", "s")).toBe("oat-1"); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.02", "0.3"), { sessionId: "s" })).toBe(true); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.05", "0.6"), { sessionId: "s" })).toBe(false); + }); + + it("merges header umbrella windows onto the last real report and preserves tier limits", async () => { + const realReport = makeTieredReport("a@example.com"); + let calls = 0; + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { + calls += 1; + return realReport; + }); + + const initialReport = requireAnthropicReport(await storage.fetchUsageReports()); + expect(requireLimit(initialReport, "anthropic:7d:opus").amount.used).toBe(12); + expect(calls).toBe(1); + + expect(await storage.getApiKey("anthropic", "merge-session")).toBe("oat-1"); + const beforeIngest = Date.now(); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.05", "0.9"), { sessionId: "merge-session" })).toBe( + true, + ); + + const mergedReport = requireAnthropicReport(await storage.fetchUsageReports()); + expect(calls).toBe(1); + expect(mergedReport.fetchedAt).toBeGreaterThan(realReport.fetchedAt); + expect(mergedReport.metadata?.email).toBe("a@example.com"); + expect(mergedReport.metadata?.accountId).toBe("account-a@example.com"); + expect(mergedReport.metadata?.headersUpdatedAt).toBeGreaterThanOrEqual(beforeIngest); + expect(requireLimit(mergedReport, "anthropic:5h").amount.used).toBe(5); + expect(requireLimit(mergedReport, "anthropic:7d").amount.used).toBe(90); + expect(requireLimit(mergedReport, "anthropic:7d:opus").amount.used).toBe(12); + }); +}); + describe("AuthStorage usage cache: terminal refresh failure", () => { // Regression: a revoked refresh token used to fail the in-line OAuth refresh // inside the usage probe, get silently swallowed, then trigger the upstream diff --git a/packages/ai/test/claude-ratelimit-headers.test.ts b/packages/ai/test/claude-ratelimit-headers.test.ts new file mode 100644 index 000000000..e9d2188dc --- /dev/null +++ b/packages/ai/test/claude-ratelimit-headers.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import type { UsageLimit, UsageReport } from "../src/usage"; +import { parseClaudeRateLimitHeaders } from "../src/usage/claude"; + +const NOW = 1_780_400_000_000; + +function requireReport(report: UsageReport | null): UsageReport { + if (!report) throw new Error("expected Claude rate-limit headers to parse"); + return report; +} + +function requireLimit(report: UsageReport, id: string): UsageLimit { + const limit = report.limits.find(candidate => candidate.id === id); + if (!limit) throw new Error(`expected ${id} limit`); + return limit; +} + +describe("Claude rate-limit response headers", () => { + it("parses unified 5h and 7d header windows with percent scaling and epoch-ms resets", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-utilization": "0.0", + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-5h-status": "allowed", + "anthropic-ratelimit-unified-7d-utilization": "0.1", + "anthropic-ratelimit-unified-7d-reset": "1780531200", + "anthropic-ratelimit-unified-7d-status": "allowed", + }, + NOW, + ), + ); + + expect(report.provider).toBe("anthropic"); + expect(report.fetchedAt).toBe(NOW); + expect(report.metadata?.source).toBe("ratelimit-headers"); + expect(report.limits).toHaveLength(2); + + const fiveHour = requireLimit(report, "anthropic:5h"); + expect(fiveHour.label).toBe("Claude 5 Hour"); + expect(fiveHour.scope.provider).toBe("anthropic"); + expect(fiveHour.scope.windowId).toBe("5h"); + expect(fiveHour.scope.shared).toBe(true); + expect(fiveHour.window?.label).toBe("5 Hour"); + expect(fiveHour.window?.durationMs).toBe(5 * 60 * 60 * 1000); + expect(fiveHour.amount.used).toBe(0); + expect(fiveHour.amount.usedFraction).toBe(0); + + const sevenDay = requireLimit(report, "anthropic:7d"); + expect(sevenDay.label).toBe("Claude 7 Day"); + expect(sevenDay.scope.provider).toBe("anthropic"); + expect(sevenDay.scope.windowId).toBe("7d"); + expect(sevenDay.scope.shared).toBe(true); + expect(sevenDay.scope.tier).toBeUndefined(); + expect(sevenDay.window?.label).toBe("7 Day"); + expect(sevenDay.window?.durationMs).toBe(7 * 24 * 60 * 60 * 1000); + expect(sevenDay.window?.resetsAt).toBe(1780531200 * 1000); + expect(sevenDay.amount.used).toBe(10); + expect(sevenDay.amount.usedFraction).toBe(0.1); + }); + + it("parses a single available unified window", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-utilization": "0.25", + "anthropic-ratelimit-unified-5h-reset": "1780405800", + }, + NOW, + ), + ); + + expect(report.limits.map(limit => limit.id)).toEqual(["anthropic:5h"]); + expect(report.limits[0]?.amount.used).toBe(25); + }); + + it("returns null when no unified utilization headers are present", () => { + expect(parseClaudeRateLimitHeaders({ "anthropic-ratelimit-unified-status": "allowed" }, NOW)).toBeNull(); + }); + + it("omits a window that has reset metadata without utilization", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-7d-utilization": "0.4", + "anthropic-ratelimit-unified-7d-reset": "1780531200", + }, + NOW, + ), + ); + + expect(report.limits.map(limit => limit.id)).toEqual(["anthropic:7d"]); + expect(report.limits[0]?.amount.used).toBe(40); + }); +}); diff --git a/packages/ai/test/claude-usage-headers.test.ts b/packages/ai/test/claude-usage-headers.test.ts index fb92f5298..1489726fd 100644 --- a/packages/ai/test/claude-usage-headers.test.ts +++ b/packages/ai/test/claude-usage-headers.test.ts @@ -75,16 +75,22 @@ describe("claude usage request headers", () => { const headers = calls[0]?.init?.headers; expect(getHeaderCaseInsensitive(headers, "authorization")).toBe(`Bearer ${token}`); - expect(getHeaderCaseInsensitive(headers, "user-agent")).toBe("claude-cli/2.1.63 (external, cli)"); + expect(getHeaderCaseInsensitive(headers, "user-agent")).toBe("claude-cli/2.1.160 (external, cli)"); const beta = getHeaderCaseInsensitive(headers, "anthropic-beta"); expect(beta).toBeDefined(); const betaTokens = beta?.split(",").map(tokenValue => tokenValue.trim()) ?? []; expect(betaTokens).toContain("claude-code-20250219"); expect(betaTokens).toContain("oauth-2025-04-20"); + expect(betaTokens).toContain("context-1m-2025-08-07"); expect(betaTokens).toContain("interleaved-thinking-2025-05-14"); + expect(betaTokens).toContain("redact-thinking-2026-02-12"); expect(betaTokens).toContain("context-management-2025-06-27"); expect(betaTokens).toContain("prompt-caching-scope-2026-01-05"); + expect(betaTokens).toContain("mid-conversation-system-2026-04-07"); + expect(betaTokens).toContain("advanced-tool-use-2025-11-20"); + expect(betaTokens).toContain("effort-2025-11-24"); + expect(betaTokens).toContain("extended-cache-ttl-2025-04-11"); }); it("does not invent reset timestamps when Claude omits them", async () => { diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index 75d0ad2a2..b0ec0af0b 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -41,6 +41,46 @@ function isAgentRunRequest(payload: unknown): payload is AgentRunRequest { return !!payload && typeof payload === "object" && "$typeName" in payload; } +function toolResultContext(): Context { + return { + messages: [ + { role: "user", content: "Use the read tool.", timestamp: 1 }, + { + role: "assistant", + api: "cursor-agent", + provider: "cursor", + model: "cursor-composer-2.5", + content: [ + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "package.json" }, + }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "package contents" }], + isError: false, + timestamp: 3, + }, + ], + }; +} + describe("Cursor resolveExecHandler execHandlers binding", () => { it("invokes handler with correct this when passed as bound method", async () => { const sentinel = { tag: "bound-correctly" }; @@ -105,6 +145,7 @@ describe("Cursor system prompt encoding", () => { expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "You are a helpful assistant." }); }); }); + describe("Cursor request action encoding", () => { it("uses a resume action for empty user turns", async () => { const payload = await captureCursorPayload({ @@ -122,6 +163,12 @@ describe("Cursor request action encoding", () => { expect(payload.action?.action.case).toBe("userMessageAction"); }); + it("uses a resume action when a tool result is the final context message", async () => { + const payload = await captureCursorPayload(toolResultContext()); + + expect(payload.action?.action.case).toBe("resumeAction"); + }); + it("uses a user message action with selected context for image-only user turns", async () => { const imageData = "aW1hZ2U="; const payload = await captureCursorPayload({ @@ -201,4 +248,23 @@ describe("Cursor history encoding", () => { }), ]); }); + + it("preserves trailing tool result history for resume actions", () => { + const history = buildCursorHistoryForTest(toolResultContext().messages, -1); + + expect(history.rootPromptMessagesJson).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Use the read tool." }], + }, + { + role: "user", + content: [{ type: "text", text: "[Tool Result]\npackage contents" }], + }, + ]); + expect(history.turnUserMessagesJson).toEqual([expect.objectContaining({ text: "Use the read tool." })]); + expect(history.turnStepMessagesJson).toEqual([ + [expect.objectContaining({ assistantMessage: { text: "[Tool Result]\npackage contents" } })], + ]); + }); }); diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index 841462eee..37fe0efee 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -26,6 +26,20 @@ function makeCopilotClaudeModel(): Model<"anthropic-messages"> { maxTokens: 16000, }; } +function makeOpenCodeGoQwen37Model(): Model<"anthropic-messages"> { + return { + id: "qwen3.7-max", + name: "Qwen3.7 Max", + api: "anthropic-messages", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 65_536, + }; +} const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], @@ -61,6 +75,22 @@ describe("Anthropic Copilot auth config", () => { expect(options.defaultHeaders.Authorization).toBe(`Bearer ${token}`); }); + it("uses bearer-only auth for OpenCode Go Anthropic models", () => { + const model = makeOpenCodeGoQwen37Model(); + const token = "opencode_test_key"; + const options = buildAnthropicClientOptions({ + model, + apiKey: token, + extraBetas: [], + stream: true, + dynamicHeaders: {}, + }); + + expect(options.apiKey).toBeNull(); + expect(options.authToken).toBeNull(); + expect(options.defaultHeaders.Authorization).toBe(`Bearer ${token}`); + }); + it("unwraps structured Copilot credentials before setting Authorization", () => { const model = makeCopilotClaudeModel(); const options = buildAnthropicClientOptions({ diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 9429f7025..b01bf56b8 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -60,6 +60,24 @@ describe("Tool argument coercion", () => { expect(result.items).toEqual([1, 2, 3]); }); + it("wraps a plain string in a singleton array when schema expects string array", () => { + const tool: Tool = { + name: "t3b", + description: "", + parameters: z.object({ paths: z.array(z.string()) }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-3b", + name: "t3b", + arguments: { paths: "src/**/*.ts" }, + }; + + const result = validateToolArguments(tool, toolCall) as { paths: string[] }; + expect(result.paths).toEqual(["src/**/*.ts"]); + }); + it("parses JSON objects in string values when schema expects object", () => { const tool: Tool = { name: "t4", diff --git a/packages/ai/test/xiaomi-tp-login-integration.test.ts b/packages/ai/test/xiaomi-tp-login-integration.test.ts new file mode 100644 index 000000000..1711bfd47 --- /dev/null +++ b/packages/ai/test/xiaomi-tp-login-integration.test.ts @@ -0,0 +1,281 @@ +/** + * End-to-end integration test for Xiaomi MiMo login with a token-plan (tp-) key. + * + * Exercises the full login flow (loginXiaomi) plus the runtime model-discovery + * path (xiaomiModelManagerOptions) with a realistic tp- key, validating: + * + * 1. Login validation targets SGP, then AMS, then CN token-plan hosts + * 2. Authorization header is Bearer (not x-api-key) + * 3. Validation request body uses mimo-v2.5 (the token-plan validation model) + * 4. SGP 401 → AMS → CN fallback; all three are tried + * 5. All three returning 401 throws a descriptive error + * 6. After login, model discovery hits token-plan hosts (SGP → AMS → CN) + * 7. Model discovery stops at first successful token-plan host + */ + +import { describe, expect, it } from "bun:test"; +import { hookFetch } from "@oh-my-pi/pi-utils"; + +import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; +import { loginXiaomi } from "../src/utils/oauth/xiaomi"; + +// Realistic tp- key (same format as user's key, but a dummy value for testing) +const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; + +const TOKEN_PLAN_HOSTS = { + sgp: "token-plan-sgp.xiaomimimo.com", + ams: "token-plan-ams.xiaomimimo.com", + cn: "token-plan-cn.xiaomimimo.com", +} as const; + +const STANDARD_HOST = "api.xiaomimimo.com"; + +// ─── loginXiaomi: validation phase ───────────────────────────────────────── + +describe("loginXiaomi with tp- key", () => { + it("validates against SGP token-plan host with Bearer auth and mimo-v2.5 model", async () => { + const seen: { url: string; headers: Record; body: string }[] = []; + + using _hook = hookFetch((input, init) => { + seen.push({ + url: String(input), + headers: (init?.headers ?? {}) as Record, + body: init?.body as string, + }); + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + await loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }); + + expect(seen).toHaveLength(1); + + // 1. Hits SGP, not the standard host + expect(seen[0]!.url).toBe(`https://${TOKEN_PLAN_HOSTS.sgp}/v1/chat/completions`); + expect(seen[0]!.url).not.toContain(STANDARD_HOST); + + // 2. Uses Bearer auth, not x-api-key + expect(seen[0]!.headers.Authorization).toBe(`Bearer ${TP_KEY}`); + expect(seen[0]!.headers["x-api-key"]).toBeUndefined(); + + // 3. Uses the token-plan validation model + const body = JSON.parse(seen[0]!.body); + expect(body.model).toBe("mimo-v2.5"); + expect(body.max_tokens).toBe(1); + expect(body.messages).toEqual([{ role: "user", content: "ping" }]); + }); + + it("falls back SGP → AMS → CN during validation", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + const url = String(input); + seen.push(url); + if (url.includes(TOKEN_PLAN_HOSTS.sgp) || url.includes(TOKEN_PLAN_HOSTS.ams)) { + return new Response("Invalid API Key", { status: 401 }); + } + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + await loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }); + + // Tried SGP → AMS → succeeded on CN + expect(seen).toHaveLength(3); + expect(seen[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(seen[1]).toContain(TOKEN_PLAN_HOSTS.ams); + expect(seen[2]).toContain(TOKEN_PLAN_HOSTS.cn); + }); + + it("throws when all three token-plan hosts return 401", async () => { + using _hook = hookFetch(_input => { + return new Response("Invalid API Key", { status: 401 }); + }); + + await expect( + loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }), + ).rejects.toThrow("Xiaomi MiMo API key validation failed (401)"); + }); + + it("falls back through timeouts: SGP timeout → AMS timeout → CN success", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + const url = String(input); + seen.push(url); + if (url.includes(TOKEN_PLAN_HOSTS.sgp) || url.includes(TOKEN_PLAN_HOSTS.ams)) { + // Simulate a regional timeout + throw new DOMException("The operation was aborted due to timeout.", "AbortError"); + } + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + await loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }); + + expect(seen).toHaveLength(3); + expect(seen[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(seen[1]).toContain(TOKEN_PLAN_HOSTS.ams); + expect(seen[2]).toContain(TOKEN_PLAN_HOSTS.cn); + }); + + it("does NOT hit the standard api.xiaomimimo.com for tp- keys", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + seen.push(String(input)); + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + await loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }); + + for (const url of seen) { + expect(url).not.toContain(STANDARD_HOST); + } + }); +}); + +// ─── xiaomiModelManagerOptions: runtime model discovery ──────────────────── + +describe("xiaomiModelManagerOptions with tp- key", () => { + it("discovers models from SGP first", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + seen.push(String(input)); + return new Response(JSON.stringify({ data: [{ id: "mimo-v2.5" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const opts = xiaomiModelManagerOptions({ apiKey: TP_KEY }); + const models = await opts.fetchDynamicModels?.(); + + expect(seen).toHaveLength(1); + expect(seen[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(seen[0]).toContain("/v1/models"); + expect(models).not.toBeNull(); + }); + + it("falls back SGP → AMS → CN during discovery", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + const url = String(input); + seen.push(url); + + // SGP and AMS fail, CN succeeds + if (url.includes(TOKEN_PLAN_HOSTS.sgp) || url.includes(TOKEN_PLAN_HOSTS.ams)) { + return new Response("error", { status: 500 }); + } + return new Response(JSON.stringify({ data: [{ id: "mimo-v2.5" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const opts = xiaomiModelManagerOptions({ apiKey: TP_KEY }); + const models = await opts.fetchDynamicModels?.(); + + // All three token-plan hosts tried in order + expect(seen).toHaveLength(3); + expect(seen[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(seen[1]).toContain(TOKEN_PLAN_HOSTS.ams); + expect(seen[2]).toContain(TOKEN_PLAN_HOSTS.cn); + expect(models).not.toBeNull(); + }); + + it("returns null when all token-plan hosts fail", async () => { + using _hook = hookFetch(() => { + return new Response("error", { status: 500 }); + }); + + const opts = xiaomiModelManagerOptions({ apiKey: TP_KEY }); + const models = await opts.fetchDynamicModels?.(); + + expect(models).toBeNull(); + }); + + it("does NOT use standard host for tp- key model discovery", async () => { + const seen: string[] = []; + + using _hook = hookFetch(input => { + seen.push(String(input)); + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const opts = xiaomiModelManagerOptions({ apiKey: TP_KEY }); + await opts.fetchDynamicModels?.(); + + for (const url of seen) { + expect(url).not.toContain(STANDARD_HOST); + } + }); +}); + +// ─── Full round-trip: login → model discovery ────────────────────────────── + +describe("Xiaomi tp- full round-trip", () => { + it("login validation and model discovery both use token-plan hosts", async () => { + // Phase 1: Login + const loginUrls: string[] = []; + + using _hook1 = hookFetch(input => { + loginUrls.push(String(input)); + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + const returnedKey = await loginXiaomi({ + onPrompt: async () => TP_KEY, + onAuth: () => {}, + onProgress: () => {}, + }); + + expect(returnedKey).toBe(TP_KEY); + expect(loginUrls).toHaveLength(1); + expect(loginUrls[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(loginUrls[0]).toContain("/v1/chat/completions"); + + // Dispose hook1 (restore original fetch) + + // Phase 2: Model discovery with the returned key + const discoveryUrls: string[] = []; + + using _hook2 = hookFetch(input => { + discoveryUrls.push(String(input)); + return new Response(JSON.stringify({ data: [{ id: "mimo-v2.5" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const opts = xiaomiModelManagerOptions({ apiKey: returnedKey }); + const models = await opts.fetchDynamicModels?.(); + + expect(discoveryUrls).toHaveLength(1); + expect(discoveryUrls[0]).toContain(TOKEN_PLAN_HOSTS.sgp); + expect(discoveryUrls[0]).toContain("/v1/models"); + expect(models).not.toBeNull(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 76327eeab..af1ac8092 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,9 +12,45 @@ - Fixed generated profile aliases to pass the profile as `--profile=`, avoiding the separate argv value that could be misread as an initial prompt while still forcing the CLI's explicit profile bootstrap path. - Fixed `--alias` when run from a source checkout (`bun src/cli.ts` / `omp-test`) so the generated profile command targets that same checkout instead of a stale installed `omp` binary, while preserving the directory where the alias is invoked. - Fixed explicit `omp launch --profile ` / `omp launch --alias ` so `launch` behaves like the default command during profile bootstrap instead of blocking global profile extraction. + +## [15.8.0] - 2026-06-02 + +### Added + +- Added an all-projects scope to the session picker (`pi --resume` / `/resume`). Press `Tab` to toggle between the current folder's sessions and every session across all projects; the all-projects list is loaded lazily and shows each session's directory. When the current folder has no sessions the picker now opens straight into all-projects scope instead of printing "No sessions found". +- Migrated the Kagi web search provider to Kagi's V1 Search API (`POST /api/v1/search`), replacing the sunset V0 endpoint while keeping the `kagi` provider id, `KAGI_API_KEY` credential, and `/login kagi` flow unchanged ([#1272](https://github.com/can1357/oh-my-pi/pull/1272) by [@thismat](https://github.com/thismat)) +- Added Anthropic `anthropic-ratelimit-unified-*` response-header warming for `/usage` and the status-line usage segment, throttled to reduce direct OAuth `/usage` probes during active use. + +### Changed + +- Changed Anthropic API request metadata `user_id.device_id` to be derived from the persistent install ID so it remains stable across Anthropic account changes on the same install +- Changed the eval `parallel()` / `pipeline()` helpers to drop their `concurrency` argument and run as wide as a `task` tool batch. The worker-pool ceiling now tracks the `task.maxConcurrency` setting (default 32; `0` = run every item at once) resolved live from the host via a new `__concurrency__` bridge, instead of the old per-call `concurrency` option that defaulted to 4 and capped at 16. This stops eval fan-outs from under-using the parallelism the session is configured for. +- Changed subagent / `agent://` output ids from a numeric prefix scheme (`0-Anna`, `1-Bob`, nested `0-Anna.1-Bob`) to a name-first scheme: the requested name is used verbatim and a `-2`/`-3`/… suffix is added only when the same name recurs within a session (`Anna`, `Anna-2`, `Anna-3`). Nested ids stay grouped under the parent (`Anna.Bob`) and the live task widget renders them as `Anna>Bob`. The main agent's IRC id is now `Main` (was `0-Main`). `AgentOutputManager` still scans existing `.md` outputs on resume so it never reuses a name that would clobber a prior output. +- Changed resuming a session that belongs to a different project to switch the process into that project's working directory. `pi --resume` and the in-session `/resume` picker now `chdir` into the resumed session's `cwd` and re-scope every cwd-derived input — project dir, **project settings** (`.claude/settings.yml`, `.omp/settings.json`, path-scoped `enabledModels`/`disabledProviders`), plugin roots, capabilities, slash commands, and the ssh tool — so tools, discovery, configuration, and commands all follow the resumed project. The `SessionManager` adopts the resumed session's own `cwd`/session directory on load (rolled back if the switch fails). +- Documented `ANTHROPIC_SEARCH_API_KEY` and `ANTHROPIC_SEARCH_BASE_URL` more thoroughly in `docs/environment-variables.md`, surfaced them in `docs/tools/web_search.md`'s Anthropic provider section, and added `ANTHROPIC_SEARCH_BASE_URL` to `omp --help`'s env-var summary so the search-only overrides are discoverable alongside `ANTHROPIC_SEARCH_API_KEY` ([#1694](https://github.com/can1357/oh-my-pi/issues/1694)). +- Migrated the Kagi web search provider to Kagi's V1 Search API (`POST /api/v1/search`), replacing the sunset V0 endpoint while keeping the `kagi` provider id, `KAGI_API_KEY` credential, and `/login kagi` flow unchanged ([#1272](https://github.com/can1357/oh-my-pi/pull/1272) by [@thismat](https://github.com/thismat)) + +### Fixed + +- Fixed `read`, `search`, `find`, `ast_grep`, and `ast_edit` recovering when a model flattens multiple existing paths into one comma-, semicolon-, or space-delimited string while preserving real paths that contain delimiters. +- Fixed Exa web search reporting available without Exa credentials, which could route searches into the unauthenticated public MCP fallback and stall before trying the next provider. Availability and `searchExa()` now resolve through the standard `AuthStorage` cascade (`EXA_API_KEY` env or stored credential) ([#1695](https://github.com/can1357/oh-my-pi/issues/1695)). +- Fixed Anthropic web search ignoring `ANTHROPIC_SEARCH_BASE_URL` when credentials came from stored Anthropic auth or generic Anthropic env fallback rather than `ANTHROPIC_SEARCH_API_KEY` ([#1694](https://github.com/can1357/oh-my-pi/issues/1694)). +- Fixed `web_search` returning 401 from corporate Anthropic API gateways. `ANTHROPIC_CUSTOM_HEADERS` is now forwarded to web-search requests whenever `ANTHROPIC_BASE_URL` points to a non-Anthropic host, not only in Foundry mode ([#1693](https://github.com/can1357/oh-my-pi/issues/1693)). +- Fixed opening exported local files from WSL by sending existing paths through `wslpath -w` and launching `wslview` directly when available, avoiding `xdg-open`'s broken file-handler path translation ([#950](https://github.com/can1357/oh-my-pi/pull/950) by [@rxreyn3](https://github.com/rxreyn3)). +- Fixed `/move` (and cross-project resume) not re-scoping the live project settings to the destination directory. Changing a session's working directory now reloads the project settings layer in place (via `Settings.reloadForCwd`) so project-scoped configuration and path-scoped `enabledModels`/`disabledProviders` follow the move instead of remaining pinned to the launch directory. +- Fixed `read ` freezing the TUI on large databases. Listing tables ran an unbounded `SELECT COUNT(*)` per table, and since `bun:sqlite` executes synchronously on the same JS thread that drives rendering and input, a multi-GB database's full-table scans blocked the UI for seconds. The listing now reads the planner's `sqlite_stat1` estimate for tables above a scan cap (shown as `~N rows`) and only counts exactly when a table is provably small, reading at most `cap + 1` rows (a capped table shows `N+ rows`). On an 8.4 GB stats database the listing dropped from multi-second full scans to ~2 ms. - Fixed a module-load crash (`ReferenceError: Cannot access 'evalToolRenderer' before initialization`) triggered whenever `tools/eval` was imported before `tools/renderers`. The eval JS backend statically pulls the agent/task/sdk/extension chain, which re-enters the root barrel → `modes/components` → `tool-execution` → `renderers` while `eval.ts` was still initializing, so `renderers.ts` read `evalToolRenderer` in its TDZ. The eval TUI renderer is now split into a dependency-light `tools/eval-render.ts` that `renderers.ts` imports directly (decoupling pure rendering from the eval runtime); `eval.ts` re-exports `evalToolRenderer`/`EVAL_DEFAULT_PREVIEW_LINES` for compatibility. +- Fixed `/logout` offering providers that were only authenticated by env/config fallbacks, which made OpenCode Go/Zen appear to remain logged in after their stored credentials were removed ([#1658](https://github.com/can1357/oh-my-pi/issues/1658)). +- Fixed `omp --resume ` crashing with an uncaught exception when an interactive user declined the cross-project fork prompt. `createSessionManager` now returns `undefined` for that cancellation, while non-interactive invocations still fail with a diagnostic when they cannot answer the fork prompt ([#1668](https://github.com/can1357/oh-my-pi/issues/1668)). +- Fixed `history.db` never recording the originating session id: the `session_id` column documented for 15.6.0 was missing from the shipped storage layer, so the column was never created/populated on the write path and every prompt row had `session_id` `NULL`. Restored the `session_id` column, schema migration (`ALTER TABLE history ADD COLUMN session_id` for pre-existing databases), and `HistoryEntry.sessionId`; wired interactive mode to register `setSessionResolver(...)` so prompts are stamped with the session active at submission time (tracking fork/resume switches); and re-enabled prompt-history ranking in the `--resume` and in-session session pickers via `HistoryStorage.matchingSessionIds()`. +- Fixed `/quit` shutdown leaving the parent shell prompt at the top of the viewport after the final TUI teardown render on Linux terminals ([#1620](https://github.com/can1357/oh-my-pi/issues/1620)). +- Fixed `omp update` failing with `No version matching "X" found for specifier "@oh-my-pi/pi-coding-agent" (but package exists)` when bun saw an older catalog than the update check did. The version is resolved by querying `https://registry.npmjs.org/` directly, but `bun install -g` would then consult its on-disk manifest snapshot or a configured npm mirror (corporate proxy, Taobao, …) that hadn't replicated the release. The bun install step now runs with `--no-cache --registry=https://registry.npmjs.org/` so it hits exactly the registry the version check used ([#1686](https://github.com/can1357/oh-my-pi/issues/1686)). +- Fixed ACP mode resetting configured async/background job settings to disabled RPC defaults, so explicit ACP background-job opt-ins are preserved during startup ([#1324](https://github.com/can1357/oh-my-pi/issues/1324)). +- Fixed legacy Pi extensions loading their `__dirname`-relative assets as empty — e.g. `@plannotator/pi-extension` reading `plannotator.html`/`review-editor.html` via `readFileSync(join(__dirname, …))`, which left `planHtmlContent` empty and dropped the plugin into its "no UI support" auto-approve path. The compat layer previously mirrored every extension module into a flat temp directory, so `import.meta.url` (and thus `__dirname`) pointed at the mirror root and sibling asset reads `ENOENT`ed. Extensions now load in place from their real on-disk location — `import.meta.url` is the real source file, so asset reads resolve exactly as under the original Pi runtime. A `Bun.plugin()` `onLoad` hook scoped to the entry's relative-import graph (the exact set of source modules, never the host, other extensions, `node_modules` deps, or unrelated project files) rewrites only the legacy `@(scope)/pi-*` and `@sinclair/typebox` imports; relative siblings, the extension's own `node_modules` deps, and bundled assets all resolve natively, with no temp-directory mirroring and no asset copying ([#1674](https://github.com/can1357/oh-my-pi/issues/1674)). +- Fixed plan approval keep-context usage to use the execution model context window, avoid zero-usage aborted turns, and disable keeping context when preserved context exceeds 95%. ## [15.7.6] - 2026-06-01 + ### Added - Added `ask` option descriptions so agents can keep short labels and render explanatory text as separate muted rows in the selector. @@ -28,36 +64,16 @@ - Fixed the `eval` tool's per-cell `timeout` killing cells that were not stalled. The timeout is now a plain wall-clock budget on the cell's **own** work that is **paused only while a host-side `agent()`/`parallel()`/`llm()` bridge call is in flight** — those calls pump a heartbeat that re-arms the watchdog, so a long fanout or a slow (e.g. reasoning-tier) completion runs to completion instead of being aborted mid-flight (a subagent's time-to-first-token, a long quiet nested tool, or an entire oneshot `llm()` request no longer trip it). Nothing else re-arms the budget: ordinary compute, `print`/stdout, `log()`/`phase()`, and non-agent tool calls all count against it, so a cell that is not delegating to an agent/llm is bounded by the regular wall-clock timeout (and the timeout message no longer says "of inactivity"). The heartbeat is a pure keepalive — never persisted or rendered. ## [15.7.5] - 2026-06-01 + ### Fixed - Fixed streaming assistant responses leaving duplicated tail rows in WSL/Windows Terminal scrollback by enabling eager native-scrollback rebuilds while assistant text is actively streaming ([#1615](https://github.com/can1357/oh-my-pi/issues/1615)). - -### Fixed - - Fixed the `task` tool mangling subagent prompts when a model double-JSON-encodes a string argument: `context` and each task's `assignment`/`description` are now repaired when they arrive uniformly double-escaped (literal `\n`, `\"`, `\uXXXX`), so the subagent receives the intended prose and the call preview renders real newlines. The repair is guarded by a JSON-string round-trip and a double-encode signature, so legitimate backslashes/quotes (Windows paths, regexes, embedded quotes) are left untouched, and it is scoped to these natural-language fields only (never code-bearing tools). - -### Fixed - - Fixed `pr://` PR views omitting formal review submissions and approvals when comments are enabled ([#1600](https://github.com/can1357/oh-my-pi/issues/1600)). - -### Fixed - - Fixed subagent yield-reminder loop logging benign user/compaction aborts as `ERROR`. The catch around `session.prompt`/`waitForIdle` in `task/executor.ts` now demotes `ToolAbortError` and signal-aborted exits to `debug` and keeps `ERROR` for genuine prompt failures only ([#1623](https://github.com/can1357/oh-my-pi/issues/1623)). - -### Fixed - - Fixed `read local://` resolving to the wrong session's artifacts directory in multi-session ACP hosts (e.g. cmux). `LocalProtocolHandler.resolve` now honors `context.localProtocolOptions` supplied by the calling tool before falling back to the process-wide override or the first `main`-kind session in the global `AgentRegistry`; `read`, `find`, `search`, `ast_grep`, and `ast_edit` thread their session's options through so a `local://PLAN.md` lookup hits the calling session's `local` root instead of a sibling session's ([#1608](https://github.com/can1357/oh-my-pi/issues/1608)). - -### Fixed - - Fixed `omp` segfaulting on exit on Windows after the tiny title/memory model loaded `onnxruntime-node` (issue [#1606](https://github.com/can1357/oh-my-pi/issues/1606)). The tiny model now runs in a Bun subprocess instead of a Worker thread, so the NAPI finalizer that crashes during shutdown never executes in the agent's address space; the subprocess is `SIGKILL`'d on dispose to skip every native destructor on every platform. - -### Fixed - - Fixed unbounded MCP reconnect loop that could fork-bomb the host when a stdio MCP server completes the `initialize`/`tools/list` handshake and then exits. `MCPManager` now enforces a per-server crash circuit breaker (5 reconnects per 30 s window) on the automatic `transport.onClose` path; manual `/mcp reconnect` resets the window so users can recover after fixing the misconfiguration ([#1592](https://github.com/can1357/oh-my-pi/issues/1592)). - -### Fixed - - Fixed auto context maintenance to include the pending prompt in the pre-send token estimate, so large user turns compact history before the provider rejects an over-limit request ([#1618](https://github.com/can1357/oh-my-pi/issues/1618)). ## [15.7.4] - 2026-05-31 @@ -71,6 +87,7 @@ - Fixed plugin install failing for sources pinned to a SHA: `git.clone()` no longer adds `--depth 1` when `options.sha` is set, so the checkout of arbitrary commits succeeds instead of bailing out with "shallow clone may not contain this commit" ([#1589](https://github.com/can1357/oh-my-pi/issues/1589)). ## [15.7.3] - 2026-05-31 + ### Added - Added support for decimal and `k`/`m` suffix turn-budget directives, enabling budgets like `+1.5k` and `+2m` in eval message parsing @@ -104,17 +121,8 @@ ### Fixed - Fixed Ctrl+O tool-result expansion on POSIX terminals so offscreen tool blocks rebuild native scrollback instead of leaving stale collapsed rows above the viewport. - -### Removed - -### Fixed - - Fixed auto-discovered OpenAI-compatible / Ollama / llama.cpp / new-api proxy models defaulting to `maxTokens: 8192`, which made providers drop the streaming connection mid-response on large `write`/`edit` tool calls and surfaced as Bun's opaque `socket connection was closed unexpectedly`. The discovery cap is now `32_768` (`DISCOVERY_DEFAULT_MAX_TOKENS` in `packages/coding-agent/src/config/model-registry.ts`) and `min(contextWindow, …)` still honors smaller advertised context windows ([#1528](https://github.com/can1357/oh-my-pi/issues/1528)). - - Removed the `/drop-images` slash command; use `/shake images`, which strips every image from the session through the same `dropImages()` path. - -### Fixed - - Fixed final `agent()` completion status emissions in eval cells so the last live progress snapshot now preserves accumulated subagent metrics such as tool count and cost - Fixed `agent()` in eval to enforce plan-mode, spawn allowlist, and disabled-agent checks before launching subagents - Fixed recursive `agent()` calls from eval by enforcing the existing max subagent depth limit @@ -129,6 +137,7 @@ - Fixed duplicated/stale scrollback above a streaming tool result on POSIX terminals (macOS/Linux). A tool whose output grows and re-lays-out (e.g. an edit diff gaining hunks) re-renders rows that already scrolled into native scrollback; the unknown-viewport anti-yank deferral left the old copy in place while the new one rendered below, showing the block twice. The event controller now enables the TUI's eager native-scrollback rebuild while a foreground tool is executing (`setEagerNativeScrollbackRebuild`), so those offscreen re-renders rebuild history cleanly — a snap to the tail is acceptable mid-tool. Background-running tools and plain assistant-text streaming keep the no-yank deferral; the mode resets at each turn start. ## [15.7.2] - 2026-05-31 + ### Added - Added `providers.autoThinkingModel` setting so users can choose the `auto` thinking classifier backend (online smol or local tiny-memory model) @@ -161,6 +170,7 @@ - Added an animated pending border for `bash` and `eval` execution blocks: while a command/cell is running, a single dark segment glides clockwise around the block's outer edge (top → right → bottom → left), replacing the previous static accent border. Motion is eased per edge (decelerating into each corner) and timed against a fixed lap duration mapped onto the live perimeter, so streaming a new output line or resizing the terminal nudges the segment proportionally instead of resetting its position. Driven by the existing spinner cadence and gated on the `display.shimmer` setting (no motion when `disabled`). - Added `providers.tinyModelDevice` and `providers.tinyModelDtype` settings (Providers tab) controlling local tiny-model acceleration for session titles and Mnemopi memory tasks. `providers.tinyModelDevice` selects the ONNX execution provider (`default` keeps the platform pick — DirectML on Windows, CUDA on Linux x64, CPU elsewhere); `providers.tinyModelDtype` selects quantization/precision (`default` keeps each model's shipped `q4`, e.g. `fp16` trades speed for fidelity). The `PI_TINY_DEVICE` / `PI_TINY_DTYPE` env vars override the matching setting. Also added `PI_TINY_DTYPE` as the env counterpart to `PI_TINY_DEVICE`; an unrecognized device/precision fails loudly at worker startup instead of silently loading a different one. - Added a bundled set of default rules shipped with the agent (TypeScript/Rust convention rules registered as TTSR conditions). They load via the new lowest-priority `builtin-defaults` discovery provider, so any user/project/tool rule of the same name overrides the bundled copy. Disable the whole set with `ttsr.builtinRules: false`, or drop individual rules (bundled or your own) by name via `ttsr.disabledRules`. +- Added a `symbols.spinnerFrames` field to custom theme JSON so themes can override the loader/tool-execution spinner. Accepts either a flat `string[]` (used for both spinner types) or `{ "status"?: string[], "activity"?: string[] }` to override each independently; anything not specified falls back to the symbol preset. Documented in `docs/theme.md` and validated by `theme-schema.json`. ([#1553](https://github.com/can1357/oh-my-pi/issues/1553)) ### Changed @@ -184,11 +194,8 @@ - Removed the `recipe` tool and its `recipe.enabled` setting. Task-runner targets (just/package.json/Cargo/make/Taskfile) are invoked directly through `bash`. -### Added - -- Added a `symbols.spinnerFrames` field to custom theme JSON so themes can override the loader/tool-execution spinner. Accepts either a flat `string[]` (used for both spinner types) or `{ "status"?: string[], "activity"?: string[] }` to override each independently; anything not specified falls back to the symbol preset. Documented in `docs/theme.md` and validated by `theme-schema.json`. ([#1553](https://github.com/can1357/oh-my-pi/issues/1553)) - ## [15.6.0] - 2026-05-30 + ### Added - Added prompt-mode autocomplete for supported internal URL schemes (`skill://`, `rule://`, `agent://`, `artifact://`, `local://`, `memory://`, and `omp://`) so typing those tokens now suggests existing resources as completion candidates @@ -204,6 +211,7 @@ - Added a Mnemopi-only `memory_edit` agent tool for updating, forgetting, or invalidating recalled memories by id, and added `/memory stats` plus `/memory diagnose` slash commands for backend maintenance visibility. - Added an `orchestrate` magic keyword that mirrors `ultrathink`: dropping the standalone word in a message paints it with a cool teal→violet gradient in the editor and appends a hidden system notice that switches the model into the multi-phase, parallel-subagent orchestration contract. Matching is word-bounded and case-insensitive, so `orchestrated`/`orchestrating` never trigger it. - Added a model-tier slider to the plan-approval prompt ("Plan mode - next step"). Left/right arrows move it from any list position to pick which configured role model (`cycleOrder`, e.g. `smol › default › slow`) executes the approved plan, with each tier colored by its role and the resolved model name shown beneath the track. The chosen tier is applied before dispatch and carries through the fresh/compacted execution session; the slider is hidden when fewer than two role models resolve. +- `omp plugin install` now accepts GitHub/GitLab/Bitbucket shorthand (`github:user/repo`, `gitlab:user/repo`, …) and full git URLs (`https://github.com/user/repo`, `git@github.com:user/repo`, …) in addition to npm specs and marketplace refs. ### Changed @@ -214,12 +222,6 @@ - Changed the system prompt to advertise `memory://root` only when the local memory backend is active. - Changed `todo_write` result rendering to animate completed items in place: the checkbox flips checked first, then the strikethrough reveals across the task text. -### Removed - -- Removed the standalone `ask`, `task`, and `yield` tools along with their obsolete prompts, docs, and tests; delegation now routes through persistent `delegate` agents plus IRC coordination. -- Removed the `/orchestrate` slash command; orchestration is now triggered by the `orchestrate` keyword (see Added) so the contract rides alongside the user's own prompt instead of replacing it. -- Removed the sticky Todos panel all-done drop/collapse animation; completed todo state now stays visible until the next explicit todo update changes it. - ### Fixed - Fixed profile bootstrap and alias installation edge cases: `--profile` is now still honored for `launch` argv that merely contain subcommand-shaped words, extension flags no longer parse literal text after `--`, alias installation preserves non-ENOENT shell config read failures, `/bin/sh` is rejected instead of being treated as bash, and aliases cannot shadow `omp` case-insensitively. @@ -236,10 +238,14 @@ - Fixed the bash (and `recipe`) tool result footer not rendering for failed commands. A non-zero exit threw a `ToolError`, which dropped the result details, so the styled `⟨Wall … | Timeout …⟩` footer was replaced by the raw `Wall time: … seconds` / `Command exited with code N` lines. Non-zero exits now resolve as a non-throwing error result that keeps `wallTimeMs`/`timeoutSeconds`/`exitCode`, and the footer shows `⟨Wall … | Timeout … | Exit: N⟩` with the textual notices folded out of the output pane. Aborts, timeouts, and missing-exit-status still throw as before. - Fixed selector-style UI components to honor `tui.select.up` and `tui.select.down` keybindings instead of hard-coding raw Up/Down arrow bytes ([#1535](https://github.com/can1357/oh-my-pi/issues/1535)). -### Added +### Removed + +- Removed the standalone `ask`, `task`, and `yield` tools along with their obsolete prompts, docs, and tests; delegation now routes through persistent `delegate` agents plus IRC coordination. +- Removed the `/orchestrate` slash command; orchestration is now triggered by the `orchestrate` keyword (see Added) so the contract rides alongside the user's own prompt instead of replacing it. +- Removed the sticky Todos panel all-done drop/collapse animation; completed todo state now stays visible until the next explicit todo update changes it. -- `omp plugin install` now accepts GitHub/GitLab/Bitbucket shorthand (`github:user/repo`, `gitlab:user/repo`, …) and full git URLs (`https://github.com/user/repo`, `git@github.com:user/repo`, …) in addition to npm specs and marketplace refs. ## [15.5.15] - 2026-05-30 + ### Changed - Enabled the agent loop's tool-call batch cap for Anthropic Claude sessions, cutting oversized streamed tool-use bursts into runnable batches before continuing the conversation. @@ -253,6 +259,7 @@ - Fixed Anthropic Claude tool-call batching to clear and reapply the Claude-specific batch cap whenever the session model changes ## [15.5.14] - 2026-05-29 + ### Added - Added progress status output for `llm()` calls in `eval`, including the resolved model, tier, and returned character count @@ -271,6 +278,7 @@ - Reverted the sticky `Todos` panel task glyphs to the pre-15.5.12 checkbox icons: completed tasks render `theme.checkbox.checked` (not `theme.status.success`) and in-progress tasks render `theme.checkbox.unchecked` (not the running glyph). Removed the animated spinner entirely — in-progress tasks and pending tasks with a matching in-flight subagent still highlight via the `accent` colour, but the panel now paints once per state change instead of on an 80 ms timer. Subagent auto-checkmarking, the advancing window (`selectStickyTodoWindow`), `todoMatchesAnyDescription` highlighting, and the all-done close animation are unchanged. ## [15.5.13] - 2026-05-29 + ### Breaking Changes - Changed hashline edit syntax to verb-based v4: body-bearing ops are `replace N..M:`, `insert before N:`, `insert after N:`, `insert head:`, and `insert tail:`, while bodyless `delete N..M` handles deletion. Removed `>A..B` repeat rows and the old `prepend:` / `append:` virtual insert headers; `-` rows remain rejected with a teaching error. @@ -324,9 +332,6 @@ ### Fixed - Fixed compaction surfacing raw HTTP 401/403 envelopes (e.g. `Compaction failed: 401 {"type":"error","error":{"type":"authentication_error",…}}`) instead of routing to an authenticated fallback model. The compaction layer now attaches the provider-reported HTTP status onto the thrown error, and `AgentSession`'s auth-failure detector branches on `error.status === 401 || 403` in addition to the existing `auth_unavailable` regex. When a fallback model role (e.g. `modelRoles.smol`) is configured, compaction retries it transparently; otherwise the user sees the actionable "Compaction requires usable credentials for …" hint instead of the raw provider envelope. - -### Fixed - - Fixed compiled-binary legacy plugin loading for `@earendil-works/*` imports of bundled package roots such as `@earendil-works/pi-coding-agent`; compat now rewrites all bundled pi package roots to bunfs entrypoints and resolves fallback peer dependencies through the canonical `@oh-my-pi/*` specifier. ## [15.5.8] - 2026-05-28 @@ -382,7 +387,9 @@ - Secured `vault://` reads and writes by validating URL paths and blocking traversal, absolute paths, and symlink escapes outside the selected vault root ## [15.5.7] - 2026-05-27 + ### Added + - `providers.openrouterVariant` setting (Settings → Providers → "OpenRouter Routing") to default OpenRouter requests to a routing-variant suffix (`:nitro`, `:floor`, `:online`, `:exacto`). Selectors that already name a variant (e.g. `openrouter/anthropic/claude-haiku:nitro`) keep precedence. - `generate_image` supports xAI Grok Imagine via `providers.image=xai`. Supports `grok-imagine-image` (default) and `grok-imagine-image-quality` at aspect ratios `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`. Uses the xAI Grok OAuth credential when available, otherwise `XAI_API_KEY`. @@ -394,6 +401,7 @@ - Fixed `read` URL reader mode aborting after a stalled Jina request instead of falling back to trafilatura/lynx/native: Jina (and Parallel extract) now have their own per-attempt sub-budget capped at 10s, the catch handler honours only real user cancellation, and the in-process native renderer is always attempted on already-loaded HTML ([#1449](https://github.com/can1357/oh-my-pi/issues/1449)) ## [15.5.6] - 2026-05-27 + ### Added - Support for multi-range line selectors on URLs (e.g., `:5-10,20-30`) to fetch and display multiple non-contiguous sections @@ -423,6 +431,7 @@ ## [15.5.4] - 2026-05-27 ### Breaking Changes + - Removed the package root `hashline` export so imports from the top-level entrypoint can no longer access `hashline` helpers directly ### Added @@ -446,24 +455,13 @@ - Fixed a race in `withFileLock` where a contender losing the `mkdir` race could wipe the winner's freshly-created lock directory before the winner finished writing its info file. Every lock now carries a per-process UUID token; `releaseLock(path, expectedToken)` verifies ownership before `fs.rm`, and `isLockStale` no longer returns `true` for a dir whose info file is absent but whose mtime is still inside the staleness window (or whose dir vanished mid-check). - Fixed `formatErrorMessage` not sanitising tabs or truncating oversized error strings before painting them through the theme. Errors that embedded raw file content (apply_patch failures, hashline mismatches, etc.) could break terminal alignment via raw `\t` chars or overflow the line width. - -### Fixed - - Fixed multi-section hashline edits to reject duplicate canonical targets and preflight write guards before any section is committed - -### Fixed - - Fixed `createAgentSession()` dropping the hidden `resolve` tool from the registry when no active tool sets `deferrable: true`, even though plan mode dispatches the plan-approval `resolve { action: "apply", ... }` call through a standing handler. Read-only plan-mode toolsets (e.g. `read`, `search`, `find`, `web_search`) silently activated plan mode without `resolve`, leaving the agent unable to submit the finalized plan and forcing the user to exit plan mode manually. `resolve` is now kept whenever `plan.enabled` is true, so the standing handler always has a callable tool ([#1428](https://github.com/can1357/oh-my-pi/issues/1428)) - -### Fixed - - Fixed `omp` startup and `/changelog` reading the host project's `CHANGELOG.md` as omp's — `getPackageDir()` no longer falls back to the user's `cwd` when no owning `package.json` is locatable, preventing spurious `lastChangelogVersion` writes ([#1423](https://github.com/can1357/oh-my-pi/issues/1423)) - -### Fixed - - Fixed hashline session-chain replay silently overwriting in-session edits when the model re-targeted a previously rewritten line with a stale file hash; replay now refuses unless every edit's anchor line content matches between the snapshot and the current file ([#1422](https://github.com/can1357/oh-my-pi/pull/1422)) ## [15.5.3] - 2026-05-27 + ### Breaking Changes - Disallowed inline payload on hashline `↑`, `↓`, and `:` operations (including BOF/EOF inserts), requiring payload text to be supplied on standalone `+` continuation rows @@ -477,6 +475,7 @@ - Fixed runtime model registry refresh and cache loading so providers with authoritative dynamic catalogs, including Synthetic, do not re-add deprecated bundled model IDs after discovery ([#1417](https://github.com/can1357/oh-my-pi/issues/1417)). ## [15.5.2] - 2026-05-26 + ### Breaking Changes - Changed the hashline patch format so payload continuation lines now require a leading `+`, rejecting unprefixed multiline payload rows that were previously accepted as fallback payload text @@ -541,10 +540,12 @@ ## [15.4.1] - 2026-05-26 ### Breaking Changes + - The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode - Hashline payload semantics are now strictly inline-first: the first payload line is whatever follows the sigil on the op line itself, and subsequent lines append after it. A newline immediately after `↑`/`↓`/`:` is no longer a free separator — it produces a blank first payload line. Use `LINE↓content` for a one-line insert, `LINE↓firstline\nsecondline` for two lines; bare `LINE↓` / `LINE↑` / `LINE:` (no inline payload) still insert/replace with one blank line as before. ### Added + - Added `irc.timeoutMs` setting to configure IRC message timeout duration with a default of 120 seconds - Added timeout enforcement for IRC send operations to prevent indefinite hangs when recipients are unresponsive - Added evaluator state inheritance for `task`-spawned subagents so JavaScript and Python variables are visible between a parent agent and its child sessions @@ -592,6 +593,7 @@ - Updated `MCPOAuthFlow.#resolveRegistrationEndpoint` to try origin-root well-known first, then fall back to path-prefixed well-known ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim)) ### Removed + - Removed the `installH2Fetch()` activation from CLI startup; HTTPS fetches now use Bun's default transport - Removed the `vim` edit mode along with the `VimTool` module, prompt, and supporting buffer/engine/renderer stack - Removed per-line hash anchors (2-letter bigram hashes) from hashline format @@ -611,6 +613,7 @@ - Fixed JavaScript `eval` imports to preserve module-level singletons across re-imports of unchanged local files and reload them only after edits - Fixed concurrent Python evaluator tool calls to use per-run identifiers so tool responses and output are routed to the correct execution - Fixed the `search` tool argument validation to accept a single string `paths` value as a one-path search. + ## [15.4.0] - 2026-05-26 ### Breaking Changes @@ -634,10 +637,6 @@ - Extended `formatApprovalPrompt` with payload previews for `eval` (language + first cell's code), `task` (agent + first task's id + assignment), `ast_edit` (first op's pattern / replacement / paths), `browser` (action + tab + url + code), and `write` content (alongside path). Previously these all rendered as bare `Allow tool: ` lines, giving the user no signal about what they were authorizing. - Decoupled the per-tool approval gate from extension-loading state: `ExtensionRunner` and the `ExtensionToolWrapper` per-tool gate are now constructed unconditionally in `createAgentSession`, regardless of whether any extensions are loaded. Previously the runner was created only when `extensionsResult.extensions.length > 0`, which silently disabled the entire approval system if no extensions (including `createAutoresearchExtension`) were loaded; a regression test in `approval-mode.test.ts` now locks the invariant. -### Fixed - -- Fixed `isMcpToolName` over-matching any tool name containing `__` (an extension legally named `my__feature` or `pkg__util__do` was getting falsely labelled `Origin: MCP server tool` in the approval prompt). Restricted to the canonical `mcp__` prefix only. - ### Changed - Updated hashline operation syntax to support inline payload text on the same op line for insert and replace (`ANCHOR↑/↓/...→`) while still accepting payload lines that follow @@ -649,6 +648,7 @@ ### Fixed +- Fixed `isMcpToolName` over-matching any tool name containing `__` (an extension legally named `my__feature` or `pkg__util__do` was getting falsely labelled `Origin: MCP server tool` in the approval prompt). Restricted to the canonical `mcp__` prefix only. - Fixed hashline inline payload parsing so same-line payloads containing whitespace, including tab-indented text, are preserved instead of being rejected - Fixed Bun HTTP/2 transport errors (`HTTP2StreamReset`, `HTTP2RefusedStream`, and `HTTP2EnhanceYourCalm`) to be treated as transient so the assistant now retries automatically instead of stopping on these recoverable failures - Fixed web search OAuth-backed providers (including Codex and Gemini) to use broker-managed token retrieval and account metadata, avoiding direct token-store refresh behavior that could cause search authentication failures @@ -662,16 +662,10 @@ - Fixed `adapter: "debugpy"` over-promising in the `debug` tool. `resolveAdapter("debugpy", cwd)` only checks for `python` in `PATH`; it does not verify that the `debugpy` module is importable. Both failure modes — `python` missing and `debugpy` module missing — used to collapse onto a generic `"No suitable debug adapter found"` error. The launch/attach action now throws a targeted `ToolError` naming `python` when `resolveAdapter` returns null for an explicit `adapter: "debugpy"`, and the `DapSessionManager` spawn-catch detects `"No module named debugpy"` in adapter stderr and surfaces a `pip install debugpy` hint. The Python debug tool documentation lists the install hint so the prompt and runtime diagnostics agree. - Fixed the LSP symbol-resolver `BARE_IDENTIFIER_RE` (`/^[A-Za-z_][\w]*$/`) rejecting `$`-prefixed identifiers (`$store`, `$count`, RxJS observables, Svelte stores, Angular signals). Without the word-boundary check, searching for `$store` on a line containing `bar$store` returned the offset inside the compound identifier rather than the standalone occurrence, feeding a wrong column to the LSP server. Pattern now `/^[$A-Za-z_][\w$]*$/`; the companion `IDENTIFIER_CHAR_RE` already contained `$`. - Fixed `applyWorkspaceEdit` writing all text edits before walking `documentChanges` for resource operations. LSP §3.16.2 requires clients to apply `documentChanges` in declared order, so any server emitting `{kind: "create", uri: X}` followed by a `TextDocumentEdit` for `X` (e.g. "Extract to new file" code actions, some rename responses) broke: the edit ran against a non-existent file, then the create happened. `applyWorkspaceEdit` now walks `documentChanges` once in declared order; per-URI text edits are coalesced into a pending Map and flushed immediately before any subsequent resource op for the same URI. Legacy `changes`-map-only payloads are unchanged. Folder-level `rename`/`delete` ops now flush every pending URI under the affected subtree (not just the exact target) so child-file edits queued before a parent-folder move land at the original location instead of dangling against a non-existent path on the final flush. Rename ops additionally flush pending edits queued against `renameOp.newUri` (and its descendants) **before** `fs.rename` runs, so edits intended for the pre-rename target file are applied before the rename clobbers or replaces it (relevant under `options.overwrite`/`options.ignoreIfExists`). When a `WorkspaceEdit` payload supplies both `changes` and `documentChanges`, the `documentChanges` arm is now used exclusively per LSP §3.16.2 ("if documentChanges are supplied … servers should use them in preference to changes"); previously the two were merged. - -### Fixed - - Fixed built-in `explore` agent failing every invocation with `schema_violation: files.0.ref: must not be present` on releases prior to 15.3.2 by renaming the `files[].ref` property to `files[].path` in the agent's output schema; `ref` is a JTD-reserved keyword (RFC 8927) and collides with JSON Type Definition's schema-reference form, so the converter previously dropped it from the generated JSON Schema. Defense-in-depth alongside the 15.3.2 converter fix ([#1379](https://github.com/can1357/oh-my-pi/issues/1379)). - Increased the `yield` tool's schema-validation retry budget from 1 to 3 so subagents whose first structured-output attempt mismatches the declared output schema get up to three retries before the parent's post-mortem `schema_violation` check hard-fails the task. The tool now also surfaces remaining retry attempts and an explicit "call yield again with the corrected shape" directive in each rejection message, giving the model the context it needs to converge — particularly helpful for models like GLM that tend to invent per-element field names instead of following the declared schema. - Fixed CLI PDF file arguments being decoded as raw bytes for local vision models; `.pdf` and other supported document files now go through the same Markit conversion path as the `read` tool before entering the prompt ([#1401](https://github.com/can1357/oh-my-pi/issues/1401)). - Fixed the `bash` tool hanging until the 305 s hard timeout when a command writes a file via heredoc on Windows (bodies > ~4 KiB) or macOS (bodies > 16-64 KiB). Root cause was in the embedded brush shell; see `@oh-my-pi/pi-natives` changelog for the underlying fix. - -### Fixed - - Fixed `/review` custom prompt orchestration text to use static prompt templates and consistently instruct reviewer task delegation. - Fixed `/review` custom-instructions submission on terminals that cannot distinguish Ctrl+Enter by using prompt-style input where Enter submits and Shift+Enter inserts a newline. - Fixed hook editor submissions sending large-paste placeholders such as `[paste #1 +27 lines]` instead of the pasted content. @@ -684,6 +678,7 @@ - Added `tools.approvalMode` global setting (Interaction tab in `/settings`) with values `auto` | `prompt` | `custom`. Defaults to `auto` so the agent runs every tool call without interruption — matching the `--auto-approve` / `--yolo` CLI flag. `prompt` uses built-in per-tool defaults only (read/find/search auto-allow; bash/edit/write/eval/ssh require confirmation; `tools.approval.` config is ignored). `custom` makes the `tools.approval.` config the source of truth — your settings win over built-in defaults, which fall back only for tools you haven't configured. CLI `--auto-approve` always wins. Critical safety patterns (e.g. `rm -rf /`, `curl … | bash`, fork bombs) keep prompting even when the tool is user-allowed. ## [15.3.2] - 2026-05-25 + ### Added - Added inline `|TEXT` payload support to `»` and `«` hashline insert operations, allowing single-line inserts on the op line and still supporting additional payload lines @@ -711,8 +706,11 @@ - Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session ### Fixed + - Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280)) + ## [15.2.4] - 2026-05-22 + ### Breaking Changes - Replaced the legacy `@@` header and `+`/`<`/`=`/`-` hashline syntax with the new `§PATH` header and `«`/`»`/`≔` operation format, so existing hashline scripts and prompts using old symbols must be updated @@ -734,6 +732,7 @@ - Fixed hashline payload handling in parser and streaming preview to preserve blank lines as actual payload text until the next op, file header, or envelope marker ## [15.2.3] - 2026-05-22 + ### Breaking Changes - Changed PR and task-isolation worktree directory layout to hash-based `~/.omp/wt/-` style paths, replacing the previous nested encoded-repo layout @@ -771,9 +770,6 @@ ### Fixed - Fixed compaction routing to the wrong provider when `modelRoles.default` is set to a different model than the active chat. Auto- and manual compaction now prefer the active session's model and only fall back to role-based candidates when the current model has no usable credentials. Previously, an Anthropic chat with `modelRoles.default = openai/gpt-5` would compact through OpenAI (including the remote-compaction endpoint), even though the live conversation never used OpenAI. - -### Fixed - - Fixed context overflow not being detected when the session's model (e.g. `hf:zai-org/GLM-5.1` via `synthetic` provider) returns a 400 with the upstream "400 status code (no body)" message wrapped inside a JSON envelope. The `isContextOverflow` check now matches the no-body status phrase anywhere in the error string rather than requiring it at the very start, so auto-compaction fires correctly instead of leaving the session silently stuck ([#1251](https://github.com/can1357/oh-my-pi/issues/1251)). - Fixed `formatCapturedHttpError` not extracting the error text from `{"error":"string"}` response bodies (only object-valued `error` fields were previously handled), resulting in raw JSON in error messages instead of the human-readable string. @@ -786,60 +782,32 @@ ### Fixed - Fixed false-positive hashline `~ TEXT` separator-padding warning firing on YAML, JSON, Python, Markdown, TOML, and other indent-sensitive file edits. The padding check is now skipped entirely for indentation-sensitive extensions (`.py`, `.yml`/`.yaml`, `.md`/`.mdx`, `.json`/`.jsonc`/`.json5`, `.toml`, `.rst`, `.tf`, `.nix`, `.coffee`, `.haml`/`.slim`/`.pug`, `.sass`/`.styl`, `.nim`, `.cr`, `.elm`, `.fs`, …), and tightened on every other extension to flag only the `~ beta` typo shape (exactly one leading space before non-space content) rather than any leading-space payload. - -### Fixed - - Fixed goal state machine: `get` now returns paused goals (was returning null for `enabled=false`), `complete` now works on paused goals after interrupts, `create` is allowed after a previous goal reaches `complete` status, and the goal tool is re-added to the active tool set on session reload when a paused goal is persisted. Added `resume` and `drop` ops to the goal tool so the agent can re-engage or discard a paused goal without requiring user slash commands. ([#1249](https://github.com/can1357/oh-my-pi/issues/1249)) ## [15.1.9] - 2026-05-21 -### Fixed - -- Fixed `disabledProviders` still probing local discovery endpoints for Ollama, llama.cpp, and LM Studio during background model refresh. Disabled providers are now excluded before implicit and built-in discovery managers are created. ([#1232](https://github.com/can1357/oh-my-pi/issues/1232)) - -### Fixed - -- Fixed `omp acp` auto-discovering host `.mcp.json` servers in parallel with the ACP client's `session/new.mcpServers`, which shadowed client-supplied MCP tools in `search_tool_bm25` and the session tool registry. The ACP session factory now forces `enableMCP: false`, so MCP ownership stays with `AcpAgent#configureMcpServers`. Non-ACP modes keep on-disk discovery. ([#1234](https://github.com/can1357/oh-my-pi/issues/1234)) - -### Fixed - -- Fixed binary `omp update` rollbacks so a downloaded replacement that fails post-install version verification no longer remains installed over the previous working binary. ([#1240](https://github.com/can1357/oh-my-pi/issues/1240)) - -### Fixed - -- Fixed `/force ` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236)) - -### Fixed - -- Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221)) - ### Added - Added OSC 8 terminal hyperlink support for file paths in tool output. When the terminal supports hyperlinks (kitty, Ghostty, WezTerm, iTerm2, Alacritty, VS Code) and the new `tui.hyperlinks` setting is `auto` (default) or `always`, OMP wraps file paths emitted by `read`, `find`, `search`, `edit`, `ast_grep`, and `ast_edit` renderers in `file:///abs/path` hyperlinks. `local://` and other fs-backed internal URLs resolve to their backing path. Set `tui.hyperlinks: off` to disable. ([#1244](https://github.com/can1357/oh-my-pi/issues/1244)) +### Fixed + +- Fixed `disabledProviders` still probing local discovery endpoints for Ollama, llama.cpp, and LM Studio during background model refresh. Disabled providers are now excluded before implicit and built-in discovery managers are created. ([#1232](https://github.com/can1357/oh-my-pi/issues/1232)) +- Fixed `omp acp` auto-discovering host `.mcp.json` servers in parallel with the ACP client's `session/new.mcpServers`, which shadowed client-supplied MCP tools in `search_tool_bm25` and the session tool registry. The ACP session factory now forces `enableMCP: false`, so MCP ownership stays with `AcpAgent#configureMcpServers`. Non-ACP modes keep on-disk discovery. ([#1234](https://github.com/can1357/oh-my-pi/issues/1234)) +- Fixed binary `omp update` rollbacks so a downloaded replacement that fails post-install version verification no longer remains installed over the previous working binary. ([#1240](https://github.com/can1357/oh-my-pi/issues/1240)) +- Fixed `/force ` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236)) +- Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221)) + ## [15.1.8] - 2026-05-20 ### Fixed - Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land. - Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring. - -### Fixed - - Fixed `legacy-pi-compat` failing to load plugin extensions (e.g. `pi-schedule-prompt@0.3.0`) that import `@mariozechner/pi-ai` when running from a compiled binary. `getResolvedSpecifier` called `Bun.resolveSync` against `import.meta.dir` inside `/$bunfs/root`, where the virtual FS exposes no resolvable `node_modules` tree at runtime; the throw silently dropped the plugin. The fix lets `rewriteLegacyPiImports` fall back gracefully on resolution failure so that `rewriteBareImportsForLegacyExtension` — which already runs immediately after — can resolve the original specifier against the plugin's own installed peer deps instead. The same fallback is applied to `resolveLegacyPiSpecifier` (the Bun plugin shim's `onResolve` handler) for tool/hook files loaded directly via Bun's import system. ([#1215](https://github.com/can1357/oh-my-pi/issues/1215)) ## [15.1.7] - 2026-05-19 -### Fixed - -- Fixed `debug` launch/attach failures so `configurationDone` no longer masks the underlying DAP launch error, early stop-outcome watchers cannot emit unhandled rejections, and directory-valued launch programs are rejected before adapter selection. ([#1187](https://github.com/can1357/oh-my-pi/issues/1187)) -- Fixed hashline edit payloads that use a readability space after `~` by warning on separator-padding-shaped payload blocks and tightening the model prompt. ([#1166](https://github.com/can1357/oh-my-pi/issues/1166)) -- Fixed ACP bash permission requests to include execute tool metadata and command content so clients can render command approval prompts consistently. ([#1189](https://github.com/can1357/oh-my-pi/issues/1189)) -- Fixed the status-line fast-mode indicator (`⚡`) rendering for scoped service tiers (`openai-only`, `claude-only`) even when the active model's provider didn't realize them — e.g. `serviceTier: "openai-only"` would still show the indicator next to a Claude model the wire request couldn't apply fast mode to. The indicator now consults a new `AgentSession.isFastModeActive()` predicate that runs the configured tier through `resolveServiceTier(tier, model.provider)` and only lights up when the result is `"priority"` for the current model. `isFastModeEnabled()` keeps its scope-aware semantics so `/fast on|off|toggle` and `/fast status` continue to reflect the user's configured intent. -### Fixed - -- Fixed status-line context% computation freezing the UI for ~1.1 s every 2 s on long sessions (2,000+ messages). The earlier alignment fix (which uses `computeContextBreakdown` to match the `/context` slash command) was running on every agent event via `updateEditorTopBorder()` (event-controller.ts:163), and `computeContextBreakdown` walks every message through the native `countTokens` tokenizer (~0.5 ms each) — for the user's 2,312-message session this was ~1,120 ms synchronous blocking per cache miss, producing the user-visible "jittery rendering" and "status bar disappearing during streaming". `StatusLineComponent.getCachedContextBreakdown()` now uses an incremental per-message token cache: messages are walked ONCE during warm-up, and subsequent refreshes only compute tokens for the NEW messages appended since last call (typically 0–1 per refresh during streaming). The LAST message is always recomputed because its content may still be growing mid-stream; all prior messages are immutable once a newer message exists. Compaction (messages array shrinks) resets the cache. Non-message tokens (system prompt + tools + skills) are cached separately and invalidated via a cheap identity fingerprint. Result: 2,300-message warm refresh drops from ~1,120 ms to ~0.04 ms — 28,000× faster. Functional parity with the prior `computeContextBreakdown` path is preserved. - ### Added - Added scoped service tier values to the `serviceTier` setting: `priority (OpenAI only)` and `priority (Claude only)`. They let you opt into premium processing on one provider family without paying premium costs on the other when switching models mid-session. `/fast on` continues to set the unscoped `"priority"` (active everywhere supported); `/fast status` and `isFastModeEnabled()` now report `on` for any scoped value too. @@ -848,6 +816,14 @@ - Changed `/fast` to be a single provider-agnostic toggle: enabling the command sets `serviceTier: "priority"` for every provider, and the anthropic-messages provider translates `priority` into `speed: "fast"` plus the `fast-mode-2026-02-01` beta. Anthropic fast mode is currently supported on Claude Opus 4.6 and 4.7; the server rejects other models, which triggers the provider's auto-fallback (request retried without the priority signal, `providerSessionState.fastModeDisabled` persisted for the rest of the session). The session listens for the `"priority"` marker in `AssistantMessage.disabledFeatures`, syncs `/fast` off, and emits a warning notice. Re-running `/fast on` clears the per-session disable so the next request actually re-tries priority. +### Fixed + +- Fixed `debug` launch/attach failures so `configurationDone` no longer masks the underlying DAP launch error, early stop-outcome watchers cannot emit unhandled rejections, and directory-valued launch programs are rejected before adapter selection. ([#1187](https://github.com/can1357/oh-my-pi/issues/1187)) +- Fixed hashline edit payloads that use a readability space after `~` by warning on separator-padding-shaped payload blocks and tightening the model prompt. ([#1166](https://github.com/can1357/oh-my-pi/issues/1166)) +- Fixed ACP bash permission requests to include execute tool metadata and command content so clients can render command approval prompts consistently. ([#1189](https://github.com/can1357/oh-my-pi/issues/1189)) +- Fixed the status-line fast-mode indicator (`⚡`) rendering for scoped service tiers (`openai-only`, `claude-only`) even when the active model's provider didn't realize them — e.g. `serviceTier: "openai-only"` would still show the indicator next to a Claude model the wire request couldn't apply fast mode to. The indicator now consults a new `AgentSession.isFastModeActive()` predicate that runs the configured tier through `resolveServiceTier(tier, model.provider)` and only lights up when the result is `"priority"` for the current model. `isFastModeEnabled()` keeps its scope-aware semantics so `/fast on|off|toggle` and `/fast status` continue to reflect the user's configured intent. +- Fixed status-line context% computation freezing the UI for ~1.1 s every 2 s on long sessions (2,000+ messages). The earlier alignment fix (which uses `computeContextBreakdown` to match the `/context` slash command) was running on every agent event via `updateEditorTopBorder()` (event-controller.ts:163), and `computeContextBreakdown` walks every message through the native `countTokens` tokenizer (~0.5 ms each) — for the user's 2,312-message session this was ~1,120 ms synchronous blocking per cache miss, producing the user-visible "jittery rendering" and "status bar disappearing during streaming". `StatusLineComponent.getCachedContextBreakdown()` now uses an incremental per-message token cache: messages are walked ONCE during warm-up, and subsequent refreshes only compute tokens for the NEW messages appended since last call (typically 0–1 per refresh during streaming). The LAST message is always recomputed because its content may still be growing mid-stream; all prior messages are immutable once a newer message exists. Compaction (messages array shrinks) resets the cache. Non-message tokens (system prompt + tools + skills) are cached separately and invalidated via a cheap identity fingerprint. Result: 2,300-message warm refresh drops from ~1,120 ms to ~0.04 ms — 28,000× faster. Functional parity with the prior `computeContextBreakdown` path is preserved. + ## [15.1.6] - 2026-05-19 ### Fixed @@ -876,6 +852,7 @@ - Fixed the TUI model selector to keep provider tab labels separate from provider ids, so the human-readable Ollama Cloud tab refreshes and filters `ollama-cloud` models correctly. ([#1153](https://github.com/can1357/oh-my-pi/issues/1153)) ## [15.1.3] - 2026-05-17 + ### Breaking Changes - Renamed the embedded-documentation internal URL scheme from `pi://` to `omp://`. `OmpProtocolHandler` replaces `PiProtocolHandler`; update any external references accordingly. @@ -916,12 +893,10 @@ - Fixed gateway usage reporting to include cached-token totals for OpenAI Chat/Responses and to serve the last good cached report during transient upstream usage fetch failures. - Fixed auth-gateway request cancellation for requests that are already aborted before dispatch. - Fixed `/login` and `/logout` provider selector overflowing tall provider lists off-screen on small terminals. The selector now scrolls a 10-item window centered on the highlighted entry, shows a `(n/total)` indicator when windowed, and accepts PageUp/PageDown for faster navigation. - -### Fixed - - Fixed `.env` loading so malformed variable names and NUL-containing values are ignored before they can poison `Bun.env` and break bash/external process execution with `nul byte found in provided data`. ## [15.1.2] - 2026-05-15 + ### Fixed - Fixed SSH host additions/removals made inside a running session not refreshing the live `ssh` tool. `/ssh add` and `/ssh remove` now update the model-visible host list immediately, while `/reload-plugins` and `/move` refresh SSH discovery for external or project-scope config changes without restart. @@ -934,6 +909,7 @@ - Updated MCP and theme schema metadata to reference JSON Schema draft-2020-12 ## [15.1.0] - 2026-05-15 + ### Breaking Changes - Changed the extension and hook runtime API by moving schema typing from direct TypeBox imports to `TSchema` from `@oh-my-pi/pi-ai`, requiring callers who use TypeScript imports of `Type` to migrate via provided injected modules @@ -1001,7 +977,6 @@ ### Changed - Changed the `github` tool's search ops (`search_issues`, `search_prs`, `search_code`, `search_commits`) to default the `repo` scope to the current checkout's `owner/repo` when `repo` is omitted. The auto-scope is skipped when the query already carries an explicit `repo:`/`org:`/`user:`/`owner:` qualifier or when `gh repo view` cannot resolve a github remote (in which case the search proceeds across all of GitHub as before). `search_repos` is unchanged — repository-scoping there must live in the query. - - Changed bash command preprocessing to strip trailing `| head` and `| tail` pipelines (including `|&`) from each top-level segment in command chains separated by `;`, `&&`, `||`, or `&` - Changed bash fixup notices to state that stderr is already merged into stdout and to reflect that fixes were applied for multiple stripped segments when several transforms fire - Changed shell-minimizer per-line truncation marker from a bare `…` to `…[+N]`, where `N` is the count of dropped Unicode scalars. The bracketed tally disambiguates minimizer-driven cuts from genuine `…` characters in the source (paths, JSON, stack traces, etc.) and gives the agent an exact count so it can decide whether the missing tail is recoverable inline or warrants reading the `[raw output: artifact://]` footer the bash wrapper already emits when the minimizer rewrites output. Affects pipeline Stage 5 (`truncate_lines_at` in `defs/*.toml`) and the internal callers in `filters/git.rs`, `filters/listing.rs`, and `filters/lint.rs`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046)) @@ -1020,13 +995,10 @@ - Rewrote the hashline edit prompt examples to use an ASCII-only `TITLE = "Mr"` → `"Mrs"` / `"Dr"` motif instead of the previous `" • "` and `"·"` separators. Some agents had been copying the middle-dot literal characters into real edits as if they were format scaffolding (e.g. emitting payload lines like `~ ·`), since the demo inserts were near-twins of the existing string. The new example keeps every original op shape (single-line replace, multiline replace, insert AFTER/BEFORE, append, delete, blank, plus both anti-patterns) but uses content that is obviously domain-specific and clearly distinct from any payload separator. Pure prompt change; no parser, schema, or runtime behavior is affected. - Fixed startup fallback-chain validation to recognize cached runtime-discovered standard provider models, including Ollama Cloud models listed by `--list-models`, so `retry.fallbackChains` no longer warns that valid `ollama-cloud/` selectors are unknown. ([#1052](https://github.com/can1357/oh-my-pi/issues/1052)) - Fixed `discoverAgents()` ignoring `disabledProviders` for the `claude-plugins` provider. Plugin roots from `~/.claude/plugins/` were scanned unconditionally, so agents from Claude Code marketplace plugins continued to appear in `/agents` and the Agent Control Center even when `disabledProviders: [claude-plugins]` was set. The discovery path now checks `isProviderEnabled("claude-plugins")` before calling `listClaudePluginRoots()`, matching how every other capability respects the disabled-providers set. ([#1075](https://github.com/can1357/oh-my-pi/issues/1075)) - -### Fixed - - Fixed `$env:VAR` PowerShell variables being mangled on Windows when commands invoked PowerShell as a subprocess (e.g. `powershell -Command "Write-Host $env:SystemRoot"`). Brush-core applied POSIX parameter expansion to `$env` before spawning the child, leaving a dangling `:NAME`. The fix lives in `pi-shell` at env-var application time: every brush session now defines `env=$env` as an internal shell variable so `$env:NAME` expands to the literal `$env:NAME` token that PowerShell expects. The fallback is not exported, only influences brush's own expansion, and is shadowed by any user assignment to `env` (e.g. `env=prod; echo "$env:8080"` still prints `prod:8080`), so the POSIX bash contract is preserved. ([#1079](https://github.com/can1357/oh-my-pi/issues/1079)) - ## [15.0.1] - 2026-05-14 + ### Breaking Changes - Removed the dedicated `exit_plan_mode` tool and its prompt, requiring plan-mode completion to use the existing `resolve` tool path instead @@ -1069,6 +1041,7 @@ - Fixed MCP OAuth refresh failing with `HTTP 401 invalid_client` for servers that require Dynamic Client Registration (RFC 7591) and have no `oauth.clientId` configured (e.g. `mcp.linear.app`). `MCPOAuthFlow` registered a fresh public PKCE client on each authorize and discarded the issued `client_id` once the flow object went out of scope; refresh then called the provider's `/token` endpoint without a `client_id`. The flow now exposes `resolvedClientId` / `registeredClientSecret` getters, `MCPCommandController#handleOAuthFlow` returns them alongside `credentialId`, and both the initial-connect and `/mcp reauth` paths persist them into `auth.{clientId,clientSecret}` (used at refresh) and `oauth.{clientId,clientSecret}` (used by subsequent `/mcp reauth` to skip re-registration). The `MCPAddWizard` `onOAuth` callback type is now `Promise` and `#launchOAuthFlow` folds the registered credentials into wizard state. Servers with a statically-configured `oauth.clientId` (Notion, Slack, Datadog) are unaffected — `#tryRegisterClient` short-circuits and the write-back is a no-op. ([#1061](https://github.com/can1357/oh-my-pi/pull/1061) by [@ldx](https://github.com/ldx)). ## [15.0.0] - 2026-05-13 + ### Breaking Changes - Removed `op: issue_view` and `op: pr_view` from the `github` tool. Read single issues/PRs via the `read` tool against `issue://` / `pr://` (or the long form `issue:////` / `pr:////`); append `?comments=0` to drop the comments section. The `issue` and `comments` parameters were removed from the tool schema since no remaining op consumes them. Mutating ops (`pr_create`, `pr_checkout`, `pr_push`), `repo_view`, `search_*`, and `run_watch` are unchanged. @@ -1102,6 +1075,7 @@ - Aligned prompt instruction language by defining `NEVER` and `AVOID` as strict aliases for `MUST NOT` and `SHOULD NOT` in the system prompt, and standardized agent, tool, and system prompt templates to use those terms consistently - Changed `--mode acp` to apply the same stdout-quiet overrides as `--mode rpc` so no banner or status text leaks into the JSON-RPC channel - Changed ACP startup to no longer require a configured model so registry validators and clients can complete `initialize` and `authenticate` before any model is selected + ### Fixed - Deferred flushing of buffered `credential_disabled` events during extension runner initialization to a microtask so handler failures are now routed through `onError()` registrations made immediately after `initialize()`, preserving extension error reporting @@ -1239,6 +1213,7 @@ - Fixed top-level `const`, `let`, and `class` declarations in evaluated JavaScript to persist across subsequent runs by rewriting top-level declarations ## [14.9.5] - 2026-05-12 + ### Breaking Changes - Removed the `jobs://` internal URL protocol; inspect background jobs via the `job` tool's `list: true` operation instead @@ -1263,9 +1238,6 @@ - Changed `memory://` URL resolution to walk all active sessions’ memory roots and return the first matching file, so worktree-based subagents can access their own memory views as well as shared roots - Changed internal URL routing to use a shared process-global `InternalUrlRouter` and protocol handlers, so built-in tools resolve `agent://`, `artifact://`, `memory://`, `skill://`, `rule://`, `mcp://`, and `local://` URLs without requiring session-specific router wiring - Changed `mcp://` handler to use the globally registered MCP manager so MCP resource links work for agents sharing session context - -### Changed - - Changed the `ask.timeout` default from `30` (seconds) to `0` (wait indefinitely). Auto-selecting the recommended option after a fixed delay was surprising users mid-deliberation; the timer is now strictly opt-in. The legacy auto-select behavior is preserved when `ask.timeout` is set to a non-zero value, and the `ask` tool's prompt has been updated so the model expects unlimited reply time by default. ### Fixed @@ -1283,6 +1255,7 @@ - Fixed `Timed out initializing browser tab worker` on prebuilt binaries by rewriting `spawnTabWorker` to import the worker entry with `with { type: "file" }` so Bun's `--compile` bundler statically discovers and embeds `tab-worker-entry.ts` in the single-file binary ([#1011](https://github.com/can1357/oh-my-pi/issues/1011)) ## [14.9.3] - 2026-05-10 + ### Breaking Changes - Changed the `eval` tool input format to canonical `*** Begin ` ... `*** End ` cells with `*** Title`, `*** Timeout`, and `*** Reset` directives, so legacy `===== ... =====` eval inputs are no longer accepted for execution @@ -1328,6 +1301,7 @@ - Fixed IRC background exchange poll loop leaking after session disposal: `#scheduleBackgroundExchangeFlush` now stops immediately when `dispose()` is called, preventing stale `setTimeout` callbacks from firing against a torn-down agent ## [14.9.2] - 2026-05-10 + ### Added - Added `agentsMdFiles` to `WorkspaceTree` so AGENTS.md discovery results are returned with the workspace scan output @@ -1343,13 +1317,18 @@ - Fixed MCP HTTP streamable transport spamming `HTTP SSE stream error: ReadableStream already has a controller` after every JSON-RPC request whose response was returned as `text/event-stream`. The transport used to break out of the SSE iterator once the matching response was captured and then re-open `response.body` for a background drain, but the body had already been piped through a `TransformStream` and could not be re-read. The drain now runs from a single iterator that resolves the response promise inline and continues to dispatch piggybacked notifications on the same stream. ## [14.9.0] - 2026-05-10 + ### Breaking Changes - Moved hashline APIs to the dedicated `@oh-my-pi/pi-coding-agent/hashline` module, moved hash helpers to `@oh-my-pi/pi-coding-agent/hashline/hash`, and removed the legacy `edit/modes/hashline` and `edit/line-hash` source subpaths. -### Removed +### Added -- Removed hashline auto-rebase. Anchor mismatches now reject immediately so the model re-reads instead of silently relocating an edit to a hash-collision within ±5 lines, which could otherwise apply the change to the wrong region. Stale-anchor recovery via the cached read snapshot is unaffected. +- Added a debug-panel raw SSE stream viewer so stuck model/tool-call streams can be inspected live from the TUI. +- Added `get_login_providers` RPC command to list registered OAuth providers with their current authentication status (`id`, `name`, `available`, `authenticated`) +- Added `login` RPC command to trigger OAuth login for a given provider; emits an `open_url` extension UI event (fire-and-forget) carrying the auth URL and optional instructions so headless clients can open the browser, then resolves when the callback-server flow completes +- Added `open_url` variant to `RpcExtensionUIRequest` for the above +- Added `getLoginProviders()` and `login(providerId)` methods to `RpcClient` ### Fixed @@ -1364,23 +1343,14 @@ - Fixed `metadata.user_id` lacking the authenticated `account_uuid` on Anthropic OAuth requests; sessions now install a dynamic resolver via `Agent#setMetadataResolver` that builds `{ session_id, account_uuid? }` per request, looking the live OAuth account UUID up from `AuthStorage` so it stays in sync with token refreshes and login/logout transitions instead of stranding a stale value - Fixed multi-file legacy Pi extensions failing to load when sibling `.ts` files import each other via relative paths ([#983](https://github.com/can1357/oh-my-pi/issues/983)). - Fixed sub-agent dispatch silently routing to a model whose provider has no working credentials (e.g. an unqualified `modelRoles.task` id like `qwen3.6-plus-free` resolving to a provider the user is not authenticated against). Task dispatch now falls back to the parent session's active model — which by definition has working auth — when the resolved subagent model has none ([#985](https://github.com/can1357/oh-my-pi/issues/985)). - -### Added - -- Added a debug-panel raw SSE stream viewer so stuck model/tool-call streams can be inspected live from the TUI. - -### Fixed - - Fixed legacy Pi plugin extensions failing to load on Windows when their entry path contains a drive letter ([#990](https://github.com/can1357/oh-my-pi/pull/990) by [@jiwangyihao](https://github.com/jiwangyihao)). -### Added +### Removed -- Added `get_login_providers` RPC command to list registered OAuth providers with their current authentication status (`id`, `name`, `available`, `authenticated`) -- Added `login` RPC command to trigger OAuth login for a given provider; emits an `open_url` extension UI event (fire-and-forget) carrying the auth URL and optional instructions so headless clients can open the browser, then resolves when the callback-server flow completes -- Added `open_url` variant to `RpcExtensionUIRequest` for the above -- Added `getLoginProviders()` and `login(providerId)` methods to `RpcClient` +- Removed hashline auto-rebase. Anchor mismatches now reject immediately so the model re-reads instead of silently relocating an edit to a hash-collision within ±5 lines, which could otherwise apply the change to the wrong region. Stale-anchor recovery via the cached read snapshot is unaffected. ## [14.8.0] - 2026-05-09 + ### Added - Added hashline stale-anchor recovery by replaying edits against a session-scoped `read`/`search` snapshot and 3-way-merging them onto the current file when anchors no longer match @@ -1400,6 +1370,7 @@ - Fixed indefinite startup hang on large repos introduced in 14.7.6 ([#975](https://github.com/can1357/oh-my-pi/issues/975)) on two fronts: (1) `createAgentSession` was awaiting `buildAgentsMdSearch` and `buildWorkspaceTree` directly in its blocking `Promise.all`, bypassing the existing 5s preparation deadline that previously protected startup — both scans are now raced against a 5s deadline and fall back to the system-prompt fallback path on timeout; (2) `buildWorkspaceTree` now derives its listing from `git ls-files --cached --others --exclude-standard` when the workspace is a git worktree, which is O(index size) and avoids the per-call full-tree gitignore-aware native scan that the previous implementation triggered. Repos without git, or where the call fails / times out, transparently fall back to the previous native-glob path. ## [14.7.6] - 2026-05-07 + ### Changed - Changed the "Hide Thinking Blocks" setting (Ctrl+T) to also instruct the provider to omit thinking/reasoning summaries from responses, instead of just hiding them client-side. Anthropic sees `thinking.display = "omitted"` (where supported); OpenAI Responses / Azure / Codex requests drop `reasoning.summary` entirely. @@ -1412,6 +1383,7 @@ - Fixed subagents re-running expensive workspace scans (`buildAgentsMdSearch`, `buildWorkspaceTree`) on every spawn: parents now forward their already-resolved `AGENTS.md` search and workspace tree to subagents through `createAgentSession`, matching how `contextFiles`, `skills`, and `promptTemplates` are already inherited. On large monorepos this removes seconds of redundant work per `task` invocation and prevents the per-subagent system-prompt timeout warnings. ## [14.7.5] - 2026-05-07 + ### Added - Added optional `/loop` limits: `/loop 10` stops after 10 auto-iterations, while duration forms such as `/loop 10m` and `/loop 10min` stop after the time limit. @@ -1447,6 +1419,7 @@ - Fixed hashline edit streaming preview collapsing to a header-only "opaque box" when a second `@PATH` section header arrived mid-stream — earlier completed sections now stay rendered while the trailing section is still being typed. ## [14.7.2] - 2026-05-06 + ### Breaking Changes - Removed the exported `BUILTIN_TOOL_METADATA` API, including `BuiltinEntry`-style metadata exports and discoverable-built-in helper exports, which will break consumers relying on those symbols @@ -1474,6 +1447,7 @@ - Changed hashline replacement and pure-insert auto-absorb to also drop a single duplicated structural-closing line (`}`, `);`, `]`, etc.) on either boundary when keeping it would unbalance brackets. The pure-insert variant fires regardless of `edit.hashlineAutoDropPureInsertDuplicates`, while the existing 2+ line generic absorb stays gated on that setting. ## [14.7.0] - 2026-05-04 + ### Breaking Changes - Changed session system-prompt APIs to use ordered string block arrays by requiring `buildSystemPrompt`, `CreateAgentSessionOptions.systemPrompt`, `Session.rebuildSystemPrompt`, and extension `before_agent_start`/`getSystemPrompt` hooks to accept and return `systemPrompt: string[]` instead of a plain system-prompt string or separate `projectPrompt` field @@ -1518,6 +1492,7 @@ - Added Ctrl+D draft persistence: pressing Ctrl+D with text in the editor now exits the app and saves the unsent text as a per-session draft. Resuming the same session (e.g. via `--resume`) restores the draft into the editor (one-shot, removed after restore). ## [14.6.4] - 2026-05-03 + ### Added - Added `hindsight.mentalModelsEnabled`, `hindsight.mentalModelAutoSeed`, `hindsight.mentalModelRefreshIntervalMs`, and `hindsight.mentalModelMaxRenderChars` settings to control curated Hindsight mental-model activation, seeding, refresh cadence, and prompt render budget @@ -1695,9 +1670,6 @@ ### Fixed - Fixed eval startup messaging to report `eval` as unavailable when Python is unreachable and JavaScript backend is disabled - -### Fixed - - Stabilized MCP tool ordering so reconnects and refreshes no longer reorder the tools array sent to the model. Anthropic prompt caching is keyed on byte-identical tool definitions; previously, the order depended on connection sequence and a single MCP server reconnect could shuffle tools across servers and invalidate the tools cache breakpoint. - Skipped redundant system-prompt rebuilds in `AgentSession.refreshMCPTools` when the active tool set is unchanged. MCP transport flapping (e.g. routine 5-minute SSE reconnects) used to call `rebuildSystemPrompt` on every reconnect even though the resulting prompt was byte-identical, eating CPU and risking cache misses if the rebuild ever became non-deterministic. The applied-tool signature also covers `customWireName` so a wire-name flip with the rest of the tool metadata constant still forces a rebuild. @@ -2351,9 +2323,6 @@ - Fixed stale child selector reuse to correctly match chunks by checksum when multiple sibling chunks with the same name exist under the same parent - Fixed stale diagnostics being reused after unrelated file publishes by clearing cached diagnostics before refreshing file state - Fixed Codex search to use streamed answer text when final answer is an image placeholder or empty - -### Fixed - - Fixed MCP config docs and schema to use `~/.omp/agent/mcp.json` for user-scoped OMP-native MCP config while keeping project config at `/.omp/mcp.json` ## [14.0.4] - 2026-04-10 @@ -2528,6 +2497,7 @@ - Chunk-mode `read` output: recursive rendering with `$XXXX` checksum suffixes, inline large-chunk previews, and normalized `#path$XXXX` selectors between read and edit - Autoresearch: `init_experiment` options `new_segment`, `from_autoresearch_md`, `abandon_unlogged_runs`; `log_experiment` options `skip_restore` and broader `force`; `run_experiment` `force` (with warnings); pre-run dirty-path tracking; `abandonUnloggedAutoresearchRuns` and `abandonedAt` on runs - LSP: diagnostic versioning (`versionSupport`, stored document version per diagnostic set), and `waitForDiagnostics` / `getDiagnosticsForFile` options (`expectedDocumentVersion`, `allowUnversioned`) +- `/review` command now accepts inline args as custom instructions appended to the generated prompt for all structured review modes (PR-style, uncommitted, specific commit). When inline args are provided, option 4 (editor) is suppressed from the menu. The no-UI (Task tool) path forwards args as a focus hint. ### Changed @@ -2562,17 +2532,6 @@ - ACP session creation now uses a factory function to support creating new sessions for different working directories - ACP event mapping now accepts optional `getMessageId` callback for stable message ID assignment to assistant chunks -### Removed - -- Deleted `src/utils/prompt-format.ts` module; prompt formatting logic moved to `pi-utils` -- Deleted `src/utils/frontmatter.ts` module; frontmatter parsing logic moved to `pi-utils` -- Removed `waitForChildProcess` utility (child process termination now handled by native `killTree` from pi-natives) -- `grep-chunk.md` (folded into unified grep template) -- `startMacAppearanceObserver` export (use `MacAppearanceObserver.start()`) -- `copyToClipboard` export from pi-natives -- `PI_CHUNK_SPLICES` env and `chunkSplicesEnabled()` -- Autoresearch `segmentFingerprint` and related config hashing - ### Fixed - Chunk edit parameter validation: corrected detection of chunk edit operations to check for `sel` field instead of `target` @@ -2640,9 +2599,16 @@ - `/autoresearch` toggles like `/plan` when empty; slash completion no longer suggests `off`/`clear` on an empty prefix after the command - Chunk-mode read/edit edge cases (zero-width gap replaces, stale batch diagnostics, grouped Go receivers, line-count headers, parse error locations) -### Added +### Removed -- `/review` command now accepts inline args as custom instructions appended to the generated prompt for all structured review modes (PR-style, uncommitted, specific commit). When inline args are provided, option 4 (editor) is suppressed from the menu. The no-UI (Task tool) path forwards args as a focus hint. +- Deleted `src/utils/prompt-format.ts` module; prompt formatting logic moved to `pi-utils` +- Deleted `src/utils/frontmatter.ts` module; frontmatter parsing logic moved to `pi-utils` +- Removed `waitForChildProcess` utility (child process termination now handled by native `killTree` from pi-natives) +- `grep-chunk.md` (folded into unified grep template) +- `startMacAppearanceObserver` export (use `MacAppearanceObserver.start()`) +- `copyToClipboard` export from pi-natives +- `PI_CHUNK_SPLICES` env and `chunkSplicesEnabled()` +- Autoresearch `segmentFingerprint` and related config hashing ## [13.19.0] - 2026-04-05 @@ -2990,6 +2956,7 @@ - Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication - Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery - Added multiline custom input for `ask` custom answers, using the prompt-style editor without inactivity timeout while composing ([#506](https://github.com/can1357/oh-my-pi/issues/506)) +- Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results ### Changed @@ -3033,13 +3000,6 @@ - Changed session persistence logic to use atomic file rewrite when flushing unflushed sessions to prevent duplication - Removed hashline edit autocorrection for duplicated boundary lines; escaped-tab autocorrection remains available for leading `\\t` sequences -### Removed - -- Removed `command-start.md` prompt template in favor of separate initialize and resume workflows -- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines -- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor -- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines - ### Fixed - Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions @@ -3047,14 +3007,14 @@ - Fixed tab character rendering in dashboard command display and tool output summaries - Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments - Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments - -### Fixed - - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505)) -### Added +### Removed -- Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results +- Removed `command-start.md` prompt template in favor of separate initialize and resume workflows +- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines +- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor +- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines ## [13.14.0] - 2026-03-20 @@ -3281,9 +3241,6 @@ ### Changed - Path resolution on Linux redirects to XDG locations when `XDG_DATA_HOME` / `XDG_STATE_HOME` / `XDG_CACHE_HOME` environment variables are set - -### Changed - - Changed TTSR interrupt logic to respect per-rule `interruptMode` settings, falling back to global `ttsr.interruptMode` when rule-level override is not specified - Reorganized settings tabs from 12 tabs (display, agent, input, tools, config, services, bash, lsp, ttsr, status) to 8 focused tabs (appearance, model, interaction, context, editing, tools, tasks, providers) for improved discoverability - Consolidated status line settings into the Appearance tab instead of a separate Status tab @@ -3309,10 +3266,6 @@ - Changed working directory paths in system prompt to use forward slashes for consistency across platforms - Modified bash executor to fall back to one-shot shell execution after a persistent session hard timeout, preventing subsequent commands from hanging -### Removed - -- Removed bash executor hard timeout recovery test file (functionality already documented in existing entries) - ### Fixed - Fixed bash execution to fall back to one-shot shell runs after a persistent session hard timeout, preventing later commands from hanging until restart @@ -3320,6 +3273,10 @@ - Fixed AgentSession disposal to call SessionManager's `close()` method when available, ensuring proper cleanup of persistent writers - Removed redundant `path.join()` call wrapping `getHistoryDbPath()` in history-storage.ts +### Removed + +- Removed bash executor hard timeout recovery test file (functionality already documented in existing entries) + ## [13.11.1] - 2026-03-13 ### Added @@ -7397,6 +7354,28 @@ - Added auto-chdir to temp directories when starting in home unless `--allow-home` is set - Added upfront diff parsing and filtering for code review command to exclude lock files, generated code, and binary assets +### Changed + +- Changed `Ctrl+P` to cycle through role models (slow → default → smol) instead of all available models +- Changed `Shift+Ctrl+P` to cycle role models temporarily (not persisted) +- Changed Extension Control Center to scale with terminal height instead of fixed 25-line limit +- Changed review command to parse git diff upfront and provide structured context to reviewer agents +- Changed session persistence to use structured logging instead of console.error for persistence failures +- Changed find tool to use fd command for .gitignore discovery instead of Bun.Glob for better abort handling +- Changed LSP config loading to only mark overrides when servers are actually defined +- Changed task tool to require explicit task `id` field instead of auto-generating names from agent type +- Changed grep and find tools to use native Bun file APIs instead of Node.js fs module for improved performance +- Changed YouTube scraper to use async command execution with proper stream handling +- Improved rust-analyzer diagnostic polling to use version-based stability detection instead of time-based delays +- Changed theme icons for extension types to use Unicode symbols (✧, ⚒) instead of text abbreviations (SK, TL, MCP) +- Changed task tool to use short CamelCase task IDs instead of agent-based naming (e.g., 'SessionStore' instead of 'explore_0') +- Changed task tool to accept single `agent` parameter at top level instead of per-task agent specification +- Changed reviewer agent to use `complete` tool instead of `submit_review` for finishing reviews +- Changed theme icons for extensions to use Unicode symbols instead of text abbreviations +- Changed LSP file type matching to support exact filename matches in addition to extensions +- Improved rust-analyzer diagnostic polling to use version-based stability detection +- Refactored web-fetch tool to use modular scraper architecture for improved maintainability + ### Fixed - Fixed auto-chdir to only use existing directories and fall back to `tmpdir()` @@ -7421,35 +7400,6 @@ - Added scrapers for app stores and marketplaces (VS Code Marketplace, JetBrains Marketplace, Firefox Add-ons, Open VSX, Flathub, F-Droid, Snapcraft) - Added scrapers for business data (SEC EDGAR, OpenCorporates, CoinGecko) - Added scrapers for reference sources (Wikipedia, Wikidata, OpenLibrary, Choose a License) - -### Changed - -- Changed `Ctrl+P` to cycle through role models (slow → default → smol) instead of all available models -- Changed `Shift+Ctrl+P` to cycle role models temporarily (not persisted) -- Changed Extension Control Center to scale with terminal height instead of fixed 25-line limit -- Changed review command to parse git diff upfront and provide structured context to reviewer agents -- Changed session persistence to use structured logging instead of console.error for persistence failures -- Changed find tool to use fd command for .gitignore discovery instead of Bun.Glob for better abort handling -- Changed LSP config loading to only mark overrides when servers are actually defined -- Changed task tool to require explicit task `id` field instead of auto-generating names from agent type -- Changed grep and find tools to use native Bun file APIs instead of Node.js fs module for improved performance -- Changed YouTube scraper to use async command execution with proper stream handling -- Improved rust-analyzer diagnostic polling to use version-based stability detection instead of time-based delays -- Changed theme icons for extension types to use Unicode symbols (✧, ⚒) instead of text abbreviations (SK, TL, MCP) -- Changed task tool to use short CamelCase task IDs instead of agent-based naming (e.g., 'SessionStore' instead of 'explore_0') -- Changed task tool to accept single `agent` parameter at top level instead of per-task agent specification -- Changed reviewer agent to use `complete` tool instead of `submit_review` for finishing reviews -- Changed theme icons for extensions to use Unicode symbols instead of text abbreviations -- Changed LSP file type matching to support exact filename matches in addition to extensions -- Improved rust-analyzer diagnostic polling to use version-based stability detection -- Refactored web-fetch tool to use modular scraper architecture for improved maintainability - -### Removed - -- Removed `submit_review` tool - reviewers now finish via `complete` tool with structured output - -### Fixed - - Fixed session persistence to call fsync before renaming temp file for durability - Fixed duplicate persistence error logging by tracking whether error was already reported - Fixed byte counting in task output truncation to correctly handle multi-byte Unicode characters @@ -7458,6 +7408,10 @@ - Fixed parallel task execution to fail fast on first error instead of waiting for all workers - Fixed byte counting in task output truncation to handle multi-byte Unicode characters correctly +### Removed + +- Removed `submit_review` tool - reviewers now finish via `complete` tool with structured output + ## [3.30.0] - 2026-01-07 ### Added @@ -7536,10 +7490,6 @@ ## [3.21.0] - 2026-01-06 -### Changed - -- Switched from local `@oh-my-pi/pi-ai` to upstream `@oh-my-pi/pi-ai` package - ### Added - Added `webSearchProvider` setting to override auto-detection priority (Exa > Perplexity > Anthropic) @@ -7561,6 +7511,7 @@ ### Changed +- Switched from local `@oh-my-pi/pi-ai` to upstream `@oh-my-pi/pi-ai` package - Refactored tool renderers to be co-located with their respective tool implementations for improved code organization - Changed web search to try all configured providers in sequence with fallback before reporting errors - Changed default Anthropic web search model from `claude-sonnet-4-5-20250514` to `claude-haiku-4-5` diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index f86a7e7ff..849536d79 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -323,7 +323,8 @@ This separation keeps `SessionManager` logic independent from storage backend an `packages/coding-agent/src/session/history-storage.ts` (`HistoryStorage`) is not conversation state restoration. - Stores prompt history in SQLite (`history.db`) with FTS5 index (`history_fts`). -- APIs are `add(prompt, cwd?)`, `getRecent(limit)`, `search(query, limit)`. +- APIs are `add(prompt, cwd?, sessionId?)`, `getRecent(limit)`, `search(query, limit)`, `matchingSessionIds(query, limit?)`. +- Each row records the originating session via the `session_id` column (surfaced as `HistoryEntry.sessionId`), so prompts can be traced back to the session they were submitted from (e.g. for `--resume`). Interactive mode registers a resolver via `setSessionResolver(...)` so prompts added without an explicit id are stamped with the session active at `add()` time, tracking fork/resume switches. - Uses singleton `HistoryStorage.open(...)` and asynchronous insert (`setImmediate`) with duplicate-last-prompt suppression. This is command/input recall data; it does not rebuild agent message trees. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7db6fb10c..5f79b6298 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "15.7.6", + "version": "15.8.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index bde46c4b1..e1ef6a365 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -17,8 +17,8 @@ export interface AsyncJob { resultText?: string; errorText?: string; /** - * Registry id of the agent that registered the job (e.g. "0-Main", - * "3-AuthLoader"). Used by scoped cancel/list APIs so a subagent's teardown + * Registry id of the agent that registered the job (e.g. "Main", + * "AuthLoader"). Used by scoped cancel/list APIs so a subagent's teardown * does not cancel its parent's jobs. Undefined for callers that don't * supply an id (e.g. legacy tests, SDK consumers without an agent context). */ @@ -58,7 +58,7 @@ export interface AsyncJobRegisterOptions { /** * Filter applied to job query/cancel APIs. With `ownerId`, results are * restricted to jobs registered by that agent (registry id from - * `AgentRegistry`, e.g. "0-Main", "3-AuthLoader"). + * `AgentRegistry`, e.g. "Main", "AuthLoader"). */ export interface AsyncJobFilter { ownerId?: string; diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index a52f1530b..fef9c33f8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -30,6 +30,7 @@ export interface Args { systemPrompt?: string; appendSystemPrompt?: string; thinking?: Effort; + hideThinking?: boolean; continue?: boolean; resume?: string | true; help?: boolean; @@ -177,6 +178,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map) ANTHROPIC_FOUNDRY_API_KEY - Anthropic token used as Authorization: Bearer in Foundry mode - ANTHROPIC_CUSTOM_HEADERS - Extra Foundry headers (e.g., "user-id: USERNAME") + ANTHROPIC_CUSTOM_HEADERS - Extra headers for Foundry or any custom ANTHROPIC_BASE_URL gateway (e.g., "user-id: USERNAME") CLAUDE_CODE_CLIENT_CERT - Client certificate (PEM path or inline PEM) for mTLS CLAUDE_CODE_CLIENT_KEY - Client private key (PEM path or inline PEM) for mTLS NODE_EXTRA_CA_CERTS - CA bundle path (or inline PEM) for server certificate validation @@ -248,7 +251,8 @@ export function getExtraHelpText(): string { PERPLEXITY_API_KEY - Perplexity web search (API) PERPLEXITY_COOKIES - Perplexity web search (session cookie) TAVILY_API_KEY - Tavily web search - ANTHROPIC_SEARCH_API_KEY - Anthropic search provider + ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY) + ANTHROPIC_SEARCH_BASE_URL - Anthropic web search base URL (override; pairs with ANTHROPIC_SEARCH_API_KEY) ${chalk.dim("# Configuration")} OMP_PROFILE - Named profile for isolated agent state (same as --profile) diff --git a/packages/coding-agent/src/cli/claude-trace-cli.ts b/packages/coding-agent/src/cli/claude-trace-cli.ts new file mode 100644 index 000000000..f776b1dbc --- /dev/null +++ b/packages/coding-agent/src/cli/claude-trace-cli.ts @@ -0,0 +1,783 @@ +/** + * Fully automated Claude Code /v1/messages capture helper. + * + * Starts a local CONNECT proxy, MITMs TLS using a local self-signed debug + * certificate, drives Claude Code through a headless PTY/xterm, and returns the + * first completed /v1/messages request/response exchange. + */ +import * as net from "node:net"; +import * as path from "node:path"; +import * as tls from "node:tls"; +import * as zlib from "node:zlib"; +import { PtySession } from "@oh-my-pi/pi-natives"; +import xterm from "@xterm/headless"; + +const DEFAULT_PROXY_HOST = "127.0.0.1"; +const DEFAULT_PROXY_PORT = 8080; +const DEFAULT_COMMAND = "claude"; +const DEFAULT_MESSAGE = "hi"; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_INPUT_DELAY_MS = 1_000; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 40; +const DOUBLE_CRLF = Buffer.from("\r\n\r\n", "latin1"); +const CRLF = Buffer.from("\r\n", "latin1"); +const TEXT_DECODER = new TextDecoder(); + +// Debug-only local MITM certificate. Claude is launched with +// NODE_TLS_REJECT_UNAUTHORIZED=0, so the certificate has no trust value; it only +// lets Node's TLS stack complete the CONNECT tunnel handshake. +export const CLAUDE_TRACE_DEBUG_CERT = `-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUAe9omAqLbydZc5ZYZGhwbbpMSF0wDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQb21wLWNsYXVkZS10cmFjZTAeFw0yNjA2MDIwODA2MjFa +Fw0zNjA1MzAwODA2MjFaMBsxGTAXBgNVBAMMEG9tcC1jbGF1ZGUtdHJhY2UwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCmpGe5T8B0oA2L82Rn5JJdXOBS +ZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYYcxSm0MAVs63w +08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRvm3g/sxN/wGtr +C5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+9IA27R5cMJS5 +oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8nWP+uleTNOiT +ExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5wArawGw9AgMB +AAGjUzBRMB0GA1UdDgQWBBQa4Ir8P3GAolZoPiuB4V2cq3riAjAfBgNVHSMEGDAW +gBQa4Ir8P3GAolZoPiuB4V2cq3riAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQClPYki235gDEUu7eDm60qsAGWxbKVv4pSh+vB+xgNgzMk4aOuU +mSfp8Y8covwklph8VfDoKTaEGqqX0Q5s74Ctl6Mwy7b0u8Zztk/g4GynLocI7TQD +ftZMgZka49+FkEsjp+XZtQbO4vOL5UsccpsLhFQQQuhVyiJ4gNo/VzgvSDkBuf3Q +Rz7xFiDKCqFEoMPty4+nKEw5832FJ5mDCOyMk6fGSO8Wbt/hmRQQFu2cSdoBs0OT +AQQJETQjPkKeTDX4jdSAlOeKwfyjfdfgeQuMkzX8xafisJa66MLPzOVbIuGbvbWD +QVCd76iYPcfNK+JZUhmAUvTHSuwgJMZ6+NgI +-----END CERTIFICATE-----`; + +export const CLAUDE_TRACE_DEBUG_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCmpGe5T8B0oA2L +82Rn5JJdXOBSZX0DyBjiIK+Tqe8T3oAr41XDLnweqtrMDSBDYbVqAoKjNbaTUSYY +cxSm0MAVs63w08SfJmShZM9pElfANqXqMiyhksFgji7JEyt/rbbId207a7s5KvRv +m3g/sxN/wGtrC5LCLMlc2GWEGD8qrVIQbmLw884qvtXi70RFUPP3Wpy4wGMWSdE+ +9IA27R5cMJS5oHsO4HGB6J8VzLY+HGY2yr4BJ9qrAyjd1UetFd9RdcjyWpsbAfX8 +nWP+uleTNOiTExNz7dPt/k6OPLNmI1iT/ruRS0uUzHZTimPd67TPQR/70RaW7Bh5 +wArawGw9AgMBAAECggEADX2mhA3H0pPuj35J36X/5Me9xWM//AwOr6febwGalazg +Ctg3EOZ01/VptzaiKQetAdhoLmxidooNn9HD7JQJKPid7q7w7m1+R26mN/xrLD2A +WyBqv+iQoo+ANs5y1BMChuIxmVY/FwFk6UWDNlekuXqgzPln4okbrYTmBbaszniO +Mu1SI/3fpnTA3iJ634FUSRVoUPP8r0WEEUtpW1wAhsJR701gvKRYw/+YcRglkhm7 +T4l6TuBcgIVzUqAc3oZLHVIMKN0ZprZSeopSRozTcUANfYONakvK9Hx1qf+/rmTR +qZHg2uOxlqvxyABnwdk8rmyFx8YqUeN9jaAbbXxtBQKBgQDQRjc0gVg4STqcFqUu +FW35MZ88S7+xTuRd/EG1dpsu2lptx1yhSLTsF5GfxBQKXCQUfWrpkyuCmlV6s+wJ +H0LSyAJQ4ffBsFterQz7dRKTlhRJNk5PYn8jjNCAuBYVSbQZqZ3yZgG3CT3G5+PZ +8Ln3tJHqTRfP5B8KTMcNYiLI0wKBgQDM0/gdb9Dvdz/32GIpxwNNIb52IxnNVVrm +M69+4XNg6CqvctZFuaMQ03W2J5IKAdESaCGLz9pwHZRjfBORcw3BPKD2QkfN5NJg +hWvLlfAsblCYiCCjTCB6rf1OJOQ5fHoNFh1wqDaQCk0flsb9nlZmQQR5ZUHaJhSC +QqMmeKvMrwKBgQDDzp+sH0Z/dGlDwg59auw/caWRHG4WFmOg8L4eCmoO/H4z41B0 +2VQu+mGQYNmue733/Yl8Gz62xL5EY88vLFK4tA1pWWiCknj0Y6Fm70QNuPVNd17c +R2/cTlDgEzG/xdEqp0q1T62hFXEdBXoztZxBA2SDcQNIEeIU3uXs8SxevQKBgFp4 +acf+wody4aNERR900sV32RtvJ49lWxAA1kwxone0NF5oV7JWa2scK4r4cW3QHZuG +uQJ7HV2WAxvqCu6cpf+rGuGKpxKPNkkBxXoX0Qye8SReRCQ8lL/7J74jV1b43yP2 +l6xR8D+w/R2tyFjvXfQuVZ6VFgAX/8kFS/DLLf7rAoGAcnFgCwyzcq6FWL8iW23J +GnbZ0IQk6SPch87MzMmnOFlEXrCf5l832vwI65tNzOoB0yQoWVfBv5sb4Zy9zeFj +FbkpRZC0Kfi9PLzDV4IawoIINYthOJxIKJg+yrmrUWCggXxwdzYIYKLRIskMXoYs +mNMXfUstElEcKO7+DKiPi6U= +-----END PRIVATE KEY-----`; + +export interface HeaderEntry { + name: string; + value: string; +} + +export interface CapturedRequest { + method: string; + path: string; + version: string; + headers: HeaderEntry[]; + body: string; +} + +export interface CapturedResponse { + statusCode: number | undefined; + statusMessage: string; + version: string; + headers: HeaderEntry[]; + body: string; +} + +export interface CapturedMessagesExchange { + target: string; + request: CapturedRequest; + response: CapturedResponse; +} + +export interface ClaudeMessagesProxyOptions { + host?: string; + port?: number; + upstreamTlsRejectUnauthorized?: boolean; +} + +interface ParsedHttpMessage { + startLine: string; + method?: string; + path?: string; + version: string; + statusCode?: number; + statusMessage?: string; + headers: HeaderEntry[]; + body: Buffer; +} + +interface ConnectTarget { + host: string; + port: number; + display: string; +} + +type BodyMode = { kind: "none" } | { kind: "fixed"; length: number } | { kind: "chunked" } | { kind: "until-end" }; + +interface ParserState { + head: Omit; + bodyMode: BodyMode; +} + +interface ChunkedParseResult { + complete: boolean; + consumed: number; + body: Buffer; +} + +interface CaptureWaiter { + resolve: (exchange: CapturedMessagesExchange) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +export interface ClaudeTraceCommandArgs { + command?: string; + message?: string; + cwd?: string; + host?: string; + port?: number; + timeoutMs?: number; + inputDelayMs?: number; + json?: boolean; + upstreamTlsRejectUnauthorized?: boolean; +} + +const XtermTerminal = xterm.Terminal; + +function headerValue(headers: readonly HeaderEntry[], name: string): string | undefined { + for (const header of headers) { + if (header.name.toLowerCase() === name) return header.value; + } + return undefined; +} + +function hasChunkedTransfer(headers: readonly HeaderEntry[]): boolean { + const value = headerValue(headers, "transfer-encoding"); + return ( + value + ?.toLowerCase() + .split(",") + .some(part => part.trim() === "chunked") === true + ); +} + +function contentLength(headers: readonly HeaderEntry[]): number { + const value = headerValue(headers, "content-length"); + if (!value) return 0; + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`Invalid Content-Length header: ${value}`); + } + return parsed; +} +interface PendingCapturedRequest { + target: string; + request: CapturedRequest; +} + +function parseHeaders(headText: string): { startLine: string; headers: HeaderEntry[] } { + const lines = headText.split("\r\n"); + const startLine = lines[0] ?? ""; + const headers: HeaderEntry[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]!; + const colon = line.indexOf(":"); + if (colon <= 0) continue; + headers.push({ name: line.slice(0, colon), value: line.slice(colon + 1).trim() }); + } + return { startLine, headers }; +} + +function parseRequestStartLine(startLine: string): Pick { + const parts = startLine.split(/\s+/u); + return { + method: parts[0] ?? "", + path: parts[1] ?? "", + version: parts[2] ?? "", + }; +} + +function parseResponseStartLine( + startLine: string, +): Pick { + const match = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/u.exec(startLine); + if (!match) return { version: "", statusCode: undefined, statusMessage: "" }; + return { + version: match[1]!, + statusCode: Number.parseInt(match[2]!, 10), + statusMessage: match[3] ?? "", + }; +} + +function responseHasNoBody(statusCode: number | undefined): boolean { + if (statusCode === undefined) return false; + return (statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304; +} + +function parseChunkedBody(buffer: Buffer): ChunkedParseResult { + let offset = 0; + const chunks: Buffer[] = []; + while (true) { + const lineEnd = buffer.indexOf(CRLF, offset); + if (lineEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; + const sizeLine = buffer.subarray(offset, lineEnd).toString("latin1"); + const semicolon = sizeLine.indexOf(";"); + const sizeText = (semicolon >= 0 ? sizeLine.slice(0, semicolon) : sizeLine).trim(); + const size = Number.parseInt(sizeText, 16); + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error(`Invalid chunk size: ${sizeLine}`); + } + const dataStart = lineEnd + CRLF.length; + if (size === 0) { + if (buffer.length < dataStart + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; + if (buffer.subarray(dataStart, dataStart + CRLF.length).equals(CRLF)) { + return { complete: true, consumed: dataStart + CRLF.length, body: Buffer.concat(chunks) }; + } + const trailerEnd = buffer.indexOf(DOUBLE_CRLF, dataStart); + if (trailerEnd < 0) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; + return { complete: true, consumed: trailerEnd + DOUBLE_CRLF.length, body: Buffer.concat(chunks) }; + } + const chunkEnd = dataStart + size; + if (buffer.length < chunkEnd + CRLF.length) return { complete: false, consumed: 0, body: Buffer.alloc(0) }; + chunks.push(buffer.subarray(dataStart, chunkEnd)); + offset = chunkEnd + CRLF.length; + } +} + +class HttpMessageParser { + readonly #kind: "request" | "response"; + #buffer = Buffer.alloc(0); + #state: ParserState | null = null; + + constructor(kind: "request" | "response") { + this.#kind = kind; + } + + push(chunk: Buffer): ParsedHttpMessage[] { + if (chunk.length > 0) { + const data = Buffer.from(chunk); + this.#buffer = this.#buffer.length === 0 ? data : Buffer.concat([this.#buffer, data]); + } + return this.#drain(false); + } + + finish(): ParsedHttpMessage[] { + return this.#drain(true); + } + + #bodyMode(head: Omit): BodyMode { + if (this.#kind === "response" && responseHasNoBody(head.statusCode)) return { kind: "none" }; + if (hasChunkedTransfer(head.headers)) return { kind: "chunked" }; + const length = contentLength(head.headers); + if (length > 0) return { kind: "fixed", length }; + if (this.#kind === "response") return { kind: "until-end" }; + return { kind: "none" }; + } + + #parseHead(): ParserState | null { + const headerEnd = this.#buffer.indexOf(DOUBLE_CRLF); + if (headerEnd < 0) return null; + const headText = this.#buffer.subarray(0, headerEnd).toString("latin1"); + const { startLine, headers } = parseHeaders(headText); + this.#buffer = this.#buffer.subarray(headerEnd + DOUBLE_CRLF.length); + if (this.#kind === "request") { + const request = parseRequestStartLine(startLine); + const head = { startLine, headers, ...request }; + return { head, bodyMode: this.#bodyMode(head) }; + } + const response = parseResponseStartLine(startLine); + const head = { startLine, headers, ...response }; + return { head, bodyMode: this.#bodyMode(head) }; + } + + #drain(final: boolean): ParsedHttpMessage[] { + const messages: ParsedHttpMessage[] = []; + while (true) { + if (!this.#state) { + const state = this.#parseHead(); + if (!state) break; + this.#state = state; + } + const state = this.#state; + if (state.bodyMode.kind === "none") { + messages.push({ ...state.head, body: Buffer.alloc(0) }); + this.#state = null; + continue; + } + if (state.bodyMode.kind === "fixed") { + if (this.#buffer.length < state.bodyMode.length) break; + const body = this.#buffer.subarray(0, state.bodyMode.length); + this.#buffer = this.#buffer.subarray(state.bodyMode.length); + messages.push({ ...state.head, body }); + this.#state = null; + continue; + } + if (state.bodyMode.kind === "chunked") { + const result = parseChunkedBody(this.#buffer); + if (!result.complete) break; + this.#buffer = this.#buffer.subarray(result.consumed); + messages.push({ ...state.head, body: result.body }); + this.#state = null; + continue; + } + if (!final) break; + const body = this.#buffer; + this.#buffer = Buffer.alloc(0); + messages.push({ ...state.head, body }); + this.#state = null; + } + return messages; + } +} + +function parseConnectTarget(raw: string): ConnectTarget | null { + if (!raw) return null; + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + if (end < 0) return null; + const host = raw.slice(1, end); + const portText = raw.startsWith(":", end + 1) ? raw.slice(end + 2) : "443"; + const port = Number.parseInt(portText, 10); + if (!Number.isSafeInteger(port) || port <= 0 || port > 65535) return null; + return { host, port, display: `[${host}]:${port}` }; + } + const colon = raw.lastIndexOf(":"); + const host = colon >= 0 ? raw.slice(0, colon) : raw; + const portText = colon >= 0 ? raw.slice(colon + 1) : "443"; + const port = Number.parseInt(portText, 10); + if (!host || !Number.isSafeInteger(port) || port <= 0 || port > 65535) return null; + return { host, port, display: `${host}:${port}` }; +} + +function pathNameFromRequestTarget(requestTarget: string): string { + if (requestTarget.startsWith("http://") || requestTarget.startsWith("https://")) { + try { + return new URL(requestTarget).pathname; + } catch { + return requestTarget; + } + } + const query = requestTarget.indexOf("?"); + return query >= 0 ? requestTarget.slice(0, query) : requestTarget; +} + +function isMessagesRequest(message: ParsedHttpMessage): boolean { + return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages"; +} + +function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string { + const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim(); + try { + if (encoding === "gzip") return zlib.gunzipSync(body).toString("utf8"); + if (encoding === "br") return zlib.brotliDecompressSync(body).toString("utf8"); + if (encoding === "deflate") return zlib.inflateSync(body).toString("utf8"); + } catch { + return TEXT_DECODER.decode(body); + } + return TEXT_DECODER.decode(body); +} + +function toCapturedRequest(message: ParsedHttpMessage): CapturedRequest { + return { + method: message.method ?? "", + path: message.path ?? "", + version: message.version, + headers: message.headers, + body: decodeBody(message.headers, message.body), + }; +} + +function toCapturedResponse(message: ParsedHttpMessage): CapturedResponse { + return { + statusCode: message.statusCode, + statusMessage: message.statusMessage ?? "", + version: message.version, + headers: message.headers, + body: decodeBody(message.headers, message.body), + }; +} + +function formatHeaders(headers: readonly HeaderEntry[]): string { + return headers.map(header => `${header.name}: ${header.value}`).join("\n"); +} + +export function formatCapturedMessagesExchange(exchange: CapturedMessagesExchange): string { + const requestHeaders = formatHeaders(exchange.request.headers); + const responseHeaders = formatHeaders(exchange.response.headers); + const responseLine = + `${exchange.response.version || "HTTP"} ${exchange.response.statusCode ?? ""} ${exchange.response.statusMessage}`.trim(); + return [ + `# /v1/messages capture (${exchange.target})`, + "", + "## Request", + `${exchange.request.method} ${exchange.request.path} ${exchange.request.version}`.trim(), + "", + "### Headers", + requestHeaders, + "", + "### Body", + exchange.request.body, + "", + "## Response", + responseLine, + "", + "### Headers", + responseHeaders, + "", + "### Body", + exchange.response.body, + "", + ].join("\n"); +} + +export class ClaudeMessagesProxy { + readonly #host: string; + readonly #requestedPort: number; + readonly #upstreamTlsRejectUnauthorized: boolean; + #server: net.Server | null = null; + #sockets = new Set(); + #completed: CapturedMessagesExchange[] = []; + #waiters: CaptureWaiter[] = []; + #stopped = false; + #port = 0; + + constructor(options: ClaudeMessagesProxyOptions = {}) { + this.#host = options.host ?? DEFAULT_PROXY_HOST; + this.#requestedPort = options.port ?? DEFAULT_PROXY_PORT; + this.#upstreamTlsRejectUnauthorized = options.upstreamTlsRejectUnauthorized ?? true; + } + + get host(): string { + return this.#host; + } + + get port(): number { + return this.#port; + } + + get url(): string { + return `http://${this.#host}:${this.#port}`; + } + + async start(): Promise { + if (this.#server) return; + const server = net.createServer(socket => this.#handleConnection(socket)); + this.#server = server; + const { promise, resolve, reject } = Promise.withResolvers(); + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(this.#requestedPort, this.#host, () => { + server.off("error", onError); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Proxy did not bind to a TCP address")); + return; + } + this.#port = address.port; + resolve(); + }); + await promise; + } + + async stop(): Promise { + this.#stopped = true; + for (const waiter of this.#waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error("Proxy stopped before a /v1/messages response completed")); + } + for (const socket of this.#sockets) { + socket.destroy(); + } + this.#sockets.clear(); + const server = this.#server; + this.#server = null; + if (!server) return; + const { promise, resolve, reject } = Promise.withResolvers(); + server.close(error => { + if (error) reject(error); + else resolve(); + }); + await promise; + } + + waitForCapture(timeoutMs: number): Promise { + const existing = this.#completed.shift(); + if (existing) return Promise.resolve(existing); + if (this.#stopped) return Promise.reject(new Error("Proxy is stopped")); + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + const index = this.#waiters.findIndex(waiter => waiter.resolve === resolve); + if (index >= 0) this.#waiters.splice(index, 1); + reject(new Error("Timed out waiting for a completed /v1/messages response")); + }, timeoutMs); + this.#waiters.push({ resolve, reject, timer }); + return promise; + } + + #complete(exchange: CapturedMessagesExchange): void { + const waiter = this.#waiters.shift(); + if (waiter) { + clearTimeout(waiter.timer); + waiter.resolve(exchange); + return; + } + this.#completed.push(exchange); + } + + #track(socket: T): T { + this.#sockets.add(socket); + socket.once("close", () => this.#sockets.delete(socket)); + return socket; + } + + #handleConnection(socket: net.Socket): void { + this.#track(socket); + let buffer = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + const data = Buffer.from(chunk); + buffer = buffer.length === 0 ? data : Buffer.concat([buffer, data]); + const headerEnd = buffer.indexOf(DOUBLE_CRLF); + if (headerEnd < 0) return; + socket.off("data", onData); + const head = buffer.subarray(0, headerEnd).toString("latin1"); + const rest = buffer.subarray(headerEnd + DOUBLE_CRLF.length); + this.#handleProxyRequest(socket, head, rest); + }; + socket.on("data", onData); + socket.on("error", () => socket.destroy()); + } + + #handleProxyRequest(socket: net.Socket, head: string, rest: Buffer): void { + const firstLine = head.split("\r\n", 1)[0] ?? ""; + const parts = firstLine.split(/\s+/u); + if (parts[0] !== "CONNECT") { + socket.end("HTTP/1.1 501 Not Implemented\r\nConnection: close\r\n\r\n"); + return; + } + const target = parseConnectTarget(parts[1] ?? ""); + if (!target) { + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n", () => this.#openMitmTunnel(socket, target, rest)); + } + + #openMitmTunnel(socket: net.Socket, target: ConnectTarget, rest: Buffer): void { + void this.#openMitmTunnelAsync(socket, target, rest).catch(() => socket.destroy()); + } + + async #openMitmTunnelAsync(socket: net.Socket, target: ConnectTarget, rest: Buffer): Promise { + const clientReady = Promise.withResolvers(); + const tlsServer = tls.createServer( + { cert: CLAUDE_TRACE_DEBUG_CERT, key: CLAUDE_TRACE_DEBUG_KEY, ALPNProtocols: ["http/1.1"] }, + clientTls => { + this.#track(clientTls); + clientReady.resolve(clientTls); + }, + ); + tlsServer.once("error", error => clientReady.reject(error)); + const listening = Promise.withResolvers(); + tlsServer.listen(0, DEFAULT_PROXY_HOST, () => listening.resolve()); + await listening.promise; + const address = tlsServer.address(); + if (!address || typeof address === "string") { + tlsServer.close(); + throw new Error("Internal TLS bridge did not bind to a TCP address"); + } + const bridge = this.#track(net.connect({ host: DEFAULT_PROXY_HOST, port: address.port })); + const connected = Promise.withResolvers(); + bridge.once("connect", () => connected.resolve()); + bridge.once("error", error => connected.reject(error)); + await connected.promise; + socket.pipe(bridge); + bridge.pipe(socket); + const closeInternalServer = () => tlsServer.close(); + socket.once("close", closeInternalServer); + bridge.once("close", closeInternalServer); + if (rest.length > 0) bridge.write(rest); + const clientTls = await clientReady.promise; + const upstreamTls = this.#track( + tls.connect({ + host: target.host, + port: target.port, + servername: net.isIP(target.host) ? undefined : target.host, + rejectUnauthorized: this.#upstreamTlsRejectUnauthorized, + ALPNProtocols: ["http/1.1"], + }), + ); + const requestParser = new HttpMessageParser("request"); + const responseParser = new HttpMessageParser("response"); + const responseQueue: Array = []; + const flushResponses = (messages: ParsedHttpMessage[]) => { + for (const message of messages) { + const pending = responseQueue.shift(); + if (!pending) continue; + this.#complete({ ...pending, response: toCapturedResponse(message) }); + } + }; + clientTls.on("data", chunk => { + if (!Buffer.isBuffer(chunk)) return; + const data = Buffer.from(chunk); + upstreamTls.write(data); + const messages = requestParser.push(data); + for (const message of messages) { + if (!isMessagesRequest(message)) { + responseQueue.push(null); + continue; + } + responseQueue.push({ target: target.display, request: toCapturedRequest(message) }); + } + }); + upstreamTls.on("data", chunk => { + if (!Buffer.isBuffer(chunk)) return; + const data = Buffer.from(chunk); + clientTls.write(data); + flushResponses(responseParser.push(data)); + }); + clientTls.on("end", () => { + try { + requestParser.finish(); + } catch {} + upstreamTls.end(); + }); + upstreamTls.on("end", () => { + try { + flushResponses(responseParser.finish()); + } catch {} + clientTls.end(); + }); + clientTls.on("error", () => upstreamTls.destroy()); + upstreamTls.on("error", () => clientTls.destroy()); + clientTls.once("close", closeInternalServer); + } +} +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function shutdownPty(session: PtySession, runPromise: Promise): Promise { + try { + session.write("\x03"); + } catch {} + await Bun.sleep(100); + try { + session.kill(); + } catch {} + try { + await runPromise; + } catch {} +} + +export async function runClaudeMessagesCapture(args: ClaudeTraceCommandArgs = {}): Promise { + const proxy = new ClaudeMessagesProxy({ + host: args.host ?? DEFAULT_PROXY_HOST, + port: args.port ?? DEFAULT_PROXY_PORT, + upstreamTlsRejectUnauthorized: args.upstreamTlsRejectUnauthorized, + }); + await proxy.start(); + const session = new PtySession(); + const terminal = new XtermTerminal({ + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + disableStdin: true, + allowProposedApi: true, + scrollback: 10_000, + }); + terminal.onData(data => { + try { + session.write(data); + } catch {} + }); + const command = args.command ?? DEFAULT_COMMAND; + const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const message = args.message ?? DEFAULT_MESSAGE; + const cwd = path.resolve(args.cwd ?? process.cwd()); + const env = { + HTTPS_PROXY: proxy.url, + HTTP_PROXY: proxy.url, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + TERM: "xterm-256color", + }; + let ptyOutput = ""; + const runPromise = session.start( + { + command, + cwd, + timeoutMs, + env, + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + }, + (error, chunk) => { + if (error || !chunk) return; + ptyOutput += chunk; + if (ptyOutput.length > 20_000) ptyOutput = ptyOutput.slice(-20_000); + terminal.write(chunk); + }, + ); + try { + const outputSuffix = () => (ptyOutput.trim() ? `\n\nClaude output:\n${ptyOutput}` : ""); + void (async () => { + await Bun.sleep(args.inputDelayMs ?? DEFAULT_INPUT_DELAY_MS); + try { + session.write(`${message}\r`); + } catch (error) { + ptyOutput += `\n[omp input write failed: ${errorMessage(error)}]\n`; + } + })(); + const captureRace = proxy.waitForCapture(timeoutMs).then( + exchange => ({ kind: "capture" as const, exchange }), + error => ({ kind: "capture-error" as const, error }), + ); + const ptyRace = runPromise.then( + () => ({ kind: "pty-exit" as const }), + error => ({ kind: "pty-error" as const, error }), + ); + const first = await Promise.race([captureRace, ptyRace]); + if (first.kind === "capture") { + await shutdownPty(session, runPromise); + return first.exchange; + } + if (first.kind === "capture-error") { + throw new Error(`${errorMessage(first.error)}${outputSuffix()}`); + } + const late = await Promise.race([captureRace, Bun.sleep(250).then(() => ({ kind: "late-timeout" as const }))]); + if (late.kind === "capture") { + await shutdownPty(session, runPromise); + return late.exchange; + } + if (first.kind === "pty-error") { + throw new Error( + `Claude command failed before /v1/messages completed: ${errorMessage(first.error)}${outputSuffix()}`, + ); + } + throw new Error(`Claude command exited before /v1/messages completed${outputSuffix()}`); + } finally { + terminal.dispose(); + await proxy.stop(); + } +} + +export async function runClaudeTraceCommand(args: ClaudeTraceCommandArgs = {}): Promise { + process.stderr.write( + `Starting Claude trace proxy on ${args.host ?? DEFAULT_PROXY_HOST}:${args.port ?? DEFAULT_PROXY_PORT}\n`, + ); + const exchange = await runClaudeMessagesCapture(args); + const output = args.json ? `${JSON.stringify(exchange, null, 2)}\n` : formatCapturedMessagesExchange(exchange); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); +} diff --git a/packages/coding-agent/src/cli/flag-tables.ts b/packages/coding-agent/src/cli/flag-tables.ts index c078b5e04..0f7d36d6e 100644 --- a/packages/coding-agent/src/cli/flag-tables.ts +++ b/packages/coding-agent/src/cli/flag-tables.ts @@ -250,6 +250,7 @@ export const VALUELESS_FLAGS: ReadonlySet = new Set([ "--no-tools", "--no-lsp", "--no-pty", + "--hide-thinking", "--print", "--no-extensions", "--no-skills", diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 822aacff8..7446c98fa 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -1,23 +1,43 @@ import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { SessionSelectorComponent } from "../modes/components/session-selector"; -import type { SessionInfo } from "../session/session-manager"; +import { HistoryStorage } from "../session/history-storage"; +import { type SessionInfo, SessionManager } from "../session/session-manager"; import { FileSessionStorage } from "../session/session-storage"; -/** Show TUI session selector and return selected session path or null if cancelled */ -export async function selectSession(sessions: SessionInfo[]): Promise { - const { promise, resolve } = Promise.withResolvers(); +/** + * Show the TUI session selector and return the selected session, or null if + * cancelled. Tab toggles between current-folder and all-projects scope; the + * all-projects list is loaded lazily via `SessionManager.listAll`. + */ +export async function selectSession( + sessions: SessionInfo[], + options?: { allSessions?: SessionInfo[]; startInAllScope?: boolean }, +): Promise { + const { promise, resolve } = Promise.withResolvers(); const ui = new TUI(new ProcessTerminal()); let resolved = false; const storage = new FileSessionStorage(); + // Rank sessions with prompt-history matches too, recovering prompts the 4KB + // session-list prefix never sees. Best-effort: a missing/locked history.db + // must not break the picker. + let historyMatcher: ((query: string) => string[]) | undefined; + try { + const history = HistoryStorage.open(); + historyMatcher = (query: string) => history.matchingSessionIds(query); + } catch (error) { + logger.warn("History storage unavailable for session ranking", { error: String(error) }); + } + const showSelector = () => { const selector = new SessionSelectorComponent( sessions, - (path: string) => { + (session: SessionInfo) => { if (!resolved) { resolved = true; ui.stop(); - resolve(path); + resolve(session); } }, () => { @@ -34,10 +54,16 @@ export async function selectSession(sessions: SessionInfo[]): Promise { - // Delete handler - SessionList will show confirmation internally - await storage.deleteSessionWithArtifacts(session.path); - return true; + { + onDelete: async (session: SessionInfo) => { + // Delete handler - SessionList will show confirmation internally + await storage.deleteSessionWithArtifacts(session.path); + return true; + }, + historyMatcher, + loadAllSessions: () => SessionManager.listAll(storage), + allSessions: options?.allSessions, + startInAllScope: options?.startInAllScope, }, ); return selector; diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 60f82e980..8593a957b 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -14,6 +14,18 @@ import { theme } from "../modes/theme/theme"; const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; +/** + * Official npm registry origin. + * + * Pinned across both the version check and the bun install step so the two + * agree on which catalog they are talking to. A user's bun may be pointed at + * an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream + * registry by minutes-to-hours, in which case `getLatestRelease` would resolve + * a version the mirror has not yet replicated and the install would fail with + * `No version matching "X" found for specifier "" (but package exists)`. + * See #1686. + */ +const NPM_REGISTRY = "https://registry.npmjs.org/"; interface ReleaseInfo { tag: string; @@ -130,7 +142,7 @@ async function resolveUpdateTarget(): Promise { * Uses npm instead of GitHub API to avoid unauthenticated rate limiting. */ async function getLatestRelease(): Promise { - const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`); + const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`); if (!response.ok) { throw new Error(`Failed to fetch release info: ${response.statusText}`); } @@ -292,12 +304,33 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): } } +/** + * Build the bun argv used to globally install a specific omp version. + * + * The version is selected by hitting {@link NPM_REGISTRY} directly in + * {@link getLatestRelease}, so the install MUST observe the same catalog: + * + * - `--registry=${NPM_REGISTRY}` pins the install to the official registry + * regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy, + * Taobao, …) that hasn't yet replicated the release would otherwise reject + * a version the upstream registry already advertises. + * - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it + * re-fetches metadata from that registry on every invocation. + * + * Together these two flags make `omp update` produce exactly the registry + * lookup the version check just performed. See #1686. + */ +export function buildBunInstallArgs(expectedVersion: string): string[] { + return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`]; +} + /** * Update via bun package manager. */ async function updateViaBun(expectedVersion: string): Promise { console.log(chalk.dim("Updating via bun...")); - const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow(); + const args = buildBunInstallArgs(expectedVersion); + const result = await $`bun ${args}`.nothrow(); if (result.exitCode !== 0) { throw new Error(`bun install failed with exit code ${result.exitCode}`); } diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 1215546b6..82435c39d 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -96,6 +96,9 @@ export default class Index extends Command { description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`, options: [...THINKING_EFFORTS], }), + "hide-thinking": Flags.boolean({ + description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)", + }), hook: Flags.string({ description: "Load a hook/extension file (can be used multiple times)", multiple: true, diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index b8a89c908..1c379b972 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -32,6 +32,7 @@ interface AppKeybindings { "app.message.followUp": true; "app.message.dequeue": true; "app.clipboard.pasteImage": true; + "app.clipboard.pasteTextRaw": true; "app.clipboard.copyLine": true; "app.clipboard.copyPrompt": true; "app.session.new": true; @@ -122,6 +123,10 @@ export const KEYBINDINGS = { defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", description: "Paste image from clipboard", }, + "app.clipboard.pasteTextRaw": { + defaultKeys: ["ctrl+shift+v", "alt+shift+v"], + description: "Paste text from clipboard as raw text (no collapse)", + }, "app.clipboard.copyLine": { defaultKeys: "alt+shift+l", description: "Copy current line", @@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = { followUp: "app.message.followUp", dequeue: "app.message.dequeue", pasteImage: "app.clipboard.pasteImage", + pasteTextRaw: "app.clipboard.pasteTextRaw", copyLine: "app.clipboard.copyLine", copyPrompt: "app.clipboard.copyPrompt", newSession: "app.session.new", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 8397712c6..e03c9c8c2 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2894,7 +2894,7 @@ export const SETTINGS_SCHEMA = { label: "Auto", description: "Preferred web-search provider", }, - { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" }, + { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" }, { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" }, { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" }, { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" }, @@ -2920,7 +2920,7 @@ export const SETTINGS_SCHEMA = { }, { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" }, { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" }, - { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" }, + { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" }, { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" }, { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" }, { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 62ed1325b..c735aa770 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -365,6 +365,29 @@ export class Settings { return cloned; } + /** + * Re-scope this instance to a new working directory *in place*: reload the + * project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve + * path-scoped settings against it, and re-fire side-effect hooks (theme, + * symbols, tab width, …). Global settings and runtime overrides are preserved. + * + * Unlike {@link cloneForCwd}, this mutates the live instance, so every holder + * (the `settings` proxy, the active session, controllers) observes the new + * project scope without swapping references — used when the process changes + * directory mid-run (`/move`, cross-project resume). No-op when `cwd` is + * already the current scope. + */ + async reloadForCwd(cwd: string): Promise { + const normalized = path.normalize(cwd); + if (normalized === this.#cwd) return; + this.#cwd = normalized; + if (this.#persist) { + this.#project = await this.#loadProjectSettings(); + } + this.#rebuildMerged(); + this.#fireAllHooks(); + } + // ───────────────────────────────────────────────────────────────────────── // Accessors // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index 9e8f9fae6..5bcfe1bcf 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -99,10 +99,8 @@ async function resolvePluginDir( async function loadSkills(ctx: LoadContext): Promise> { const items: Skill[] = []; const warnings: string[] = []; - const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd); warnings.push(...rootWarnings); - const results = await Promise.all( roots.map(async root => { const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills"); @@ -114,16 +112,16 @@ async function loadSkills(ctx: LoadContext): Promise> { return { root, result, warning }; }), ); - - for (const { root, result, warning } of results) { + for (const { result, warning } of results) { if (warning) warnings.push(warning); - for (const skill of result.items) { - if (root.plugin) skill.name = `${root.plugin}:${skill.name}`; - items.push(skill); - } + // Intentionally do NOT prefix skill names with `root.plugin`. + // The `plugin:name` format breaks skill:// URL parsing (colons are + // ambiguous with port separators) and is unintuitive for callers. + // Dedup-by-key in the capability layer already handles name collisions + // across providers using priority ordering. + items.push(...result.items); if (result.warnings) warnings.push(...result.warnings); } - return { items, warnings }; } diff --git a/packages/coding-agent/src/eval/__tests__/agent-bridge.test.ts b/packages/coding-agent/src/eval/__tests__/agent-bridge.test.ts index 39274f866..939dbbe7e 100644 --- a/packages/coding-agent/src/eval/__tests__/agent-bridge.test.ts +++ b/packages/coding-agent/src/eval/__tests__/agent-bridge.test.ts @@ -106,6 +106,7 @@ function singleResult(options: ExecutorOptions, overrides: Partial function makeEvalSession( tempDir: TempDir, prefix: string, + settings?: Settings, ): { session: ToolSession; sessionFile: string; sessionId: string } { const sessionFile = path.join(tempDir.path(), "session.jsonl"); const artifactsDir = sessionFile.slice(0, -6); @@ -113,6 +114,7 @@ function makeEvalSession( cwd: tempDir.path(), sessionFile, artifactsDir, + settings, outputManager: new AgentOutputManager(() => artifactsDir), }); return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` }; @@ -261,9 +263,15 @@ describe("agent() through eval runtimes", () => { expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]); }); - it("runs JavaScript parallel() with bounded concurrency while preserving order", async () => { + it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => { using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-"); - const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel"); + const settings = Settings.isolated({ + "async.enabled": false, + "task.isolation.mode": "none", + "task.enableLsp": true, + "task.maxConcurrency": 2, + }); + const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel", settings); mockAgents(); let inFlight = 0; let maxInFlight = 0; @@ -279,7 +287,7 @@ describe("agent() through eval runtimes", () => { }); const result = await executeJs( - 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name)), { concurrency: 2 }); return JSON.stringify(values);', + 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);', { cwd: tempDir.path(), sessionId, session, sessionFile }, ); @@ -300,7 +308,7 @@ describe("agent() through eval runtimes", () => { return singleResult(options, { output: options.assignment ?? "" }); }); - const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")], { concurrency: 2 });', { + const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', { cwd: tempDir.path(), sessionId, session, @@ -343,6 +351,52 @@ describe("agent() through eval runtimes", () => { expect(result.output.trim()).toBe("hello from python"); }); + it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => { + using tempDir = TempDir.createSync("@omp-eval-agent-py-parallel-"); + const settings = Settings.isolated({ + "async.enabled": false, + "task.isolation.mode": "none", + "task.enableLsp": true, + "task.maxConcurrency": 2, + }); + const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings); + mockAgents(); + let inFlight = 0; + let maxInFlight = 0; + vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await Bun.sleep(options.assignment === "a" ? 30 : 10); + return singleResult(options, { output: options.assignment ?? "" }); + } finally { + inFlight--; + } + }); + + const probe = await executePython('print("probe")', { + cwd: tempDir.path(), + sessionId: `${sessionId}:probe`, + sessionFile, + kernelMode: "per-call", + }); + if (probe.exitCode === undefined && probe.cancelled) { + expect(probe.output).toBe(""); + return; + } + expect(probe.exitCode).toBe(0); + + const result = await executePython( + 'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))', + { cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session }, + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(2); + }); + it("streams enriched agent progress through onStatus before the cell finishes", async () => { using tempDir = TempDir.createSync("@omp-eval-agent-progress-"); const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress"); diff --git a/packages/coding-agent/src/eval/concurrency-bridge.ts b/packages/coding-agent/src/eval/concurrency-bridge.ts new file mode 100644 index 000000000..97026b1f4 --- /dev/null +++ b/packages/coding-agent/src/eval/concurrency-bridge.ts @@ -0,0 +1,34 @@ +/** + * Host-side handler for the eval `parallel()` / `pipeline()` worker pool. + * + * The pool ceiling is not a kernel-side knob: it tracks the `task.maxConcurrency` + * setting so an eval fan-out runs as wide as a `task` tool batch would. `0` means + * unbounded — run every item at once, exactly like `task.maxConcurrency = 0`. + */ +import type { ToolSession } from "../tools"; +import type { JsStatusEvent } from "./js/shared/types"; + +/** Synthetic bridge name reserved for the parallel-pool ceiling across both runtimes. */ +export const EVAL_CONCURRENCY_BRIDGE_NAME = "__concurrency__"; + +export interface EvalConcurrencyBridgeOptions { + session: ToolSession; + signal?: AbortSignal; + emitStatus?: (event: JsStatusEvent) => void; +} + +export interface EvalConcurrencyResult { + /** Worker-pool ceiling; `0` means unbounded (run every item at once). */ + limit: number; +} + +/** + * Resolve the worker-pool ceiling for an eval cell's `parallel()`/`pipeline()` + * helpers from the live `task.maxConcurrency` setting. Negative/non-finite + * values collapse to `0` (unbounded), matching the `task` tool's own handling. + */ +export function runEvalConcurrency(_args: unknown, options: EvalConcurrencyBridgeOptions): EvalConcurrencyResult { + const raw = options.session.settings.get("task.maxConcurrency"); + const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0; + return { limit: limit > 0 ? limit : 0 }; +} diff --git a/packages/coding-agent/src/eval/js/shared/prelude.txt b/packages/coding-agent/src/eval/js/shared/prelude.txt index f4dc9b1fe..141efd473 100644 --- a/packages/coding-agent/src/eval/js/shared/prelude.txt +++ b/packages/coding-agent/src/eval/js/shared/prelude.txt @@ -55,15 +55,24 @@ if (!globalThis.__omp_js_prelude_loaded__) { return hasOwn(o, "schema") ? JSON.parse(text) : text; }; - const normalizeConcurrency = value => { - const number = Number(value ?? 4); - if (!Number.isFinite(number)) return 4; - return Math.max(1, Math.min(16, Math.trunc(number))); + // Pool ceiling mirrors the task tool's `task.maxConcurrency` setting so an + // eval fan-out runs as wide as a `task` batch would. 0 (and any non-finite + // reply) means unbounded — run every item at once. + const __concurrencyLimit = async () => { + try { + const r = await globalThis.__omp_call_tool__("__concurrency__", {}); + const n = Math.trunc(Number(r && typeof r === "object" ? r.limit : r)); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } }; - const __pool = async (items, limit, fn) => { + const __pool = async (items, fn) => { const list = Array.from(items ?? []); - const concurrency = Math.min(normalizeConcurrency(limit), list.length); + if (list.length === 0) return []; + const limit = await __concurrencyLimit(); + const concurrency = limit > 0 ? Math.min(limit, list.length) : list.length; const results = new Array(list.length); let next = 0; const worker = async () => { @@ -77,23 +86,17 @@ if (!globalThis.__omp_js_prelude_loaded__) { return results; }; - const parallel = async (thunks, opts = {}) => - __pool(thunks, toOptions(opts).concurrency, (thunk, index) => { + const parallel = async thunks => + __pool(thunks, (thunk, index) => { if (typeof thunk !== "function") throw new TypeError("parallel() expects an iterable of functions"); return thunk(index); }); - const pipeline = async (items, ...stagesAndOptions) => { - let opts = {}; - const last = stagesAndOptions.at(-1); - if (last && typeof last === "object" && !Array.isArray(last)) { - opts = last; - stagesAndOptions = stagesAndOptions.slice(0, -1); - } + const pipeline = async (items, ...stages) => { let current = Array.from(items ?? []); - for (const stage of stagesAndOptions) { + for (const stage of stages) { if (typeof stage !== "function") throw new TypeError("pipeline() stages must be functions"); - current = await __pool(current, toOptions(opts).concurrency, stage); + current = await __pool(current, stage); } return current; }; diff --git a/packages/coding-agent/src/eval/js/tool-bridge.ts b/packages/coding-agent/src/eval/js/tool-bridge.ts index 2315031be..97caec9df 100644 --- a/packages/coding-agent/src/eval/js/tool-bridge.ts +++ b/packages/coding-agent/src/eval/js/tool-bridge.ts @@ -3,6 +3,7 @@ import type { ToolSession } from "../../tools"; import { ToolError } from "../../tools/tool-errors"; import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge"; import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge"; +import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge"; import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge"; import type { JsStatusEvent } from "./shared/types"; @@ -17,6 +18,7 @@ interface ToolBridgeOptions { type ToolValue = | string | EvalBudgetResult + | EvalConcurrencyResult | { text: string; details?: unknown; @@ -114,6 +116,9 @@ export async function callSessionTool(name: string, args: unknown, options: Tool if (name === EVAL_BUDGET_BRIDGE_NAME) { return await runEvalBudget(args, options); } + if (name === EVAL_CONCURRENCY_BRIDGE_NAME) { + return runEvalConcurrency(args, options); + } const tool = getTool(options.session, name); const normalizedArgs = normalizeArgs(args); const toolCallId = `js-${name}-${crypto.randomUUID()}`; diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 55e5f11bc..030107038 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -503,27 +503,35 @@ if "__omp_prelude_loaded__" not in globals(): text = res.get("text") if isinstance(res, dict) else res return json.loads(text) if schema is not None else text - def _normalize_concurrency(value): - """Clamp a concurrency hint to [1, 16], defaulting to 4 on bad input.""" - try: - n = int(value) - except (TypeError, ValueError): - n = 4 - return max(1, min(16, n)) + def _concurrency_limit(): + """Worker-pool ceiling from the host ``task.maxConcurrency`` setting. - def _pool_map(items, fn, concurrency): + An eval fan-out runs as wide as a ``task`` batch would. Returns ``0`` for + unbounded (run every item at once); falls back to ``0`` if the host + bridge is unreachable. + """ + try: + snap = _bridge_call("__concurrency__", {}) or {} + n = int(snap.get("limit") or 0) + except Exception: + return 0 + return n if n > 0 else 0 + + def _pool_map(items, fn): """Run ``fn`` over ``items`` through a bounded thread pool. Preserves input order, barriers until every task settles, and raises the lowest-index exception if any task failed. Each task runs inside a copy of the submitting thread's context so the ``_CURRENT_RID`` ContextVar - propagates and bridge calls (agent(), tool.*, etc.) keep working. + propagates and bridge calls (agent(), tool.*, etc.) keep working. The + pool width tracks ``task.maxConcurrency`` (0 = run every item at once). """ import concurrent.futures, contextvars items = list(items) if not items: return [] - workers = min(_normalize_concurrency(concurrency), len(items)) + limit = _concurrency_limit() + workers = min(limit, len(items)) if limit > 0 else len(items) results = [None] * len(items) errors = {} with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: @@ -541,30 +549,30 @@ if "__omp_prelude_loaded__" not in globals(): raise errors[min(errors)] return results - def parallel(thunks, *, concurrency=4): + def parallel(thunks): """Run zero-arg callables through a bounded pool, preserving input order. Barriers until all finish; re-raises the lowest-index exception if any - thunk raised. + thunk raised. Pool width tracks the task tool's ``task.maxConcurrency``. """ thunks = list(thunks) for t in thunks: if not callable(t): raise TypeError("parallel() expects an iterable of zero-arg callables") - return _pool_map(thunks, lambda t: t(), concurrency) + return _pool_map(thunks, lambda t: t()) - def pipeline(items, *stages, concurrency=4): + def pipeline(items, *stages): """Map items left-to-right through one-arg stage callables. Every item clears stage N before any item enters stage N+1 (barrier per stage). Stage 1 receives the original item; later stages receive the - previous stage's result. + previous stage's result. Pool width tracks ``task.maxConcurrency``. """ current = list(items) for stage in stages: if not callable(stage): raise TypeError("pipeline() stages must be callables") - current = _pool_map(current, stage, concurrency) + current = _pool_map(current, stage) return current def log(message): diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 2314390f6..90def20a2 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; import { isCompiledBinary } from "@oh-my-pi/pi-utils"; @@ -45,8 +44,6 @@ const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp( `((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`, "g", ); -const LEGACY_PI_FILE_PREFIX = "omp-legacy-pi-file:"; -const LEGACY_PI_FILE_NAMESPACE = "omp-legacy-pi-file"; const resolvedSpecifierFallbacks = new Map(); // Extensions that imported `@sinclair/typebox` directly used to resolve against a @@ -56,7 +53,6 @@ const resolvedSpecifierFallbacks = new Map(); // changes. Submodules like `@sinclair/typebox/compiler` are intentionally not // remapped — those expose TypeBox-only APIs the shim does not provide and plugins // relying on them must vendor `@sinclair/typebox` directly. -const TYPEBOX_SPECIFIER = "@sinclair/typebox"; const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/; // Compat shim and bundled-package paths used in compiled-binary mode. The shim @@ -214,129 +210,146 @@ function rewriteLegacyPiImports(source: string): string { } catch { // Resolution failed — typically in compiled binary mode where // Bun.resolveSync cannot walk up from /$bunfs/root to find the - // bundled node_modules. Return the original specifier unchanged so - // rewriteBareImportsForLegacyExtension can resolve it against the - // plugin's own installed peer deps instead. + // bundled node_modules. Leave the specifier unchanged so Bun + // resolves it natively against the extension's own peer deps. return match; } }, ); } -// Match static `from "..."` / `from '...'` import specifiers. -const STATIC_IMPORT_SPECIFIER_REGEX = /(from\s+["'])([^"']+)(["'])/g; -// Match static imports plus dynamic `import("...")` / `import('...')` specifiers. -const ANY_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])([^"']+)(["'])/g; +// Match the bare `@sinclair/typebox` import specifier (static + dynamic). +// Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded — +// they expose TypeBox-only APIs the Zod-backed shim does not provide. +const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g; -/** Resolve bare imports against the extension directory before loading mirrored legacy Pi files. */ -function isUrlLikeSpecifier(specifier: string): boolean { - // Windows drive-letter paths (e.g. `C:\foo` or `C:/foo`) also match the URL - // scheme shape `[A-Za-z][A-Za-z\d+.-]*:`. Treat them as filesystem paths so - // `toRewrittenImportSpecifier` converts them to `file://` URLs instead of - // emitting raw paths whose `\n`, `\U`, ... get eaten by TS string-literal - // escapes inside the mirrored extension file. - if (/^[a-zA-Z]:[\\/]/.test(specifier)) return false; - return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier); +/** + * Rewrite the legacy specifiers a Pi extension may import — `@(scope)/pi-*` and + * the bare `@sinclair/typebox` root — to absolute `file://` URLs pointing at the + * bundled package or compat shim. Every other specifier (relative siblings, the + * extension's own bare dependencies) is left untouched so Bun resolves it + * natively from the extension's real on-disk location. + */ +function rewriteLegacyExtensionSource(source: string): string { + const withPi = rewriteLegacyPiImports(source); + return withPi.replace( + TYPEBOX_IMPORT_SPECIFIER_REGEX, + (_match, prefix: string, _specifier: string, suffix: string) => { + return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`; + }, + ); } -function shouldPreserveImportSpecifier(specifier: string): boolean { - return specifier.startsWith(".") || path.isAbsolute(specifier) || isUrlLikeSpecifier(specifier); +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function toRewrittenImportSpecifier(resolvedPath: string): string { - return isUrlLikeSpecifier(resolvedPath) ? resolvedPath : toImportSpecifier(resolvedPath); +// Match relative import specifiers (static `from "./…"` and dynamic +// `import("./…")`). Used to walk an extension's own module graph; bare and +// absolute specifiers are deliberately excluded. +const RELATIVE_IMPORT_SPECIFIER_REGEX = /(?:from\s+|import\s*\(\s*)["'](\.\.?\/[^"']+)["']/g; + +// Extension entry realpaths that already have a load-time rewrite hook +// installed. Each `Bun.plugin()` registration is process-global and permanent, +// so we register at most one hook per entry. +const hookedExtensionEntries = new Set(); + +/** Resolve symlinks in a path, falling back to the input if realpath fails. */ +async function realpathOrSelf(p: string): Promise { + try { + return await fs.realpath(p); + } catch { + return p; + } } -function rewriteBareImportsForLegacyExtension(source: string, importerPath: string): string { - const importerDir = path.dirname(importerPath); - return source.replace(ANY_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => { - // Skip relative, absolute, URL-style, and already-resolved Node specifiers. - if (shouldPreserveImportSpecifier(specifier)) { - return match; - } - if (specifier === TYPEBOX_SPECIFIER) { - return `${prefix}${toRewrittenImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`; - } - try { - const resolved = Bun.resolveSync(specifier, importerDir); - return `${prefix}${toRewrittenImportSpecifier(resolved)}${suffix}`; - } catch { - return match; - } - }); -} - -interface LegacyPiMirrorState { - root: string; - seen: Map; -} - -function getMirrorPath(sourcePath: string, state: LegacyPiMirrorState): string { - const extension = path.extname(sourcePath) || ".js"; - const digest = Bun.hash(sourcePath).toString(36); - return path.join(state.root, `module-${digest}${extension}`); -} - -async function rewriteRelativeImportsForLegacyExtension( - source: string, - importerPath: string, - state: LegacyPiMirrorState, -): Promise { - const replacements = new Map(); - - for (const match of source.matchAll(STATIC_IMPORT_SPECIFIER_REGEX)) { - const specifier = match[2]; - if (!specifier.startsWith("./") && !specifier.startsWith("../")) { +/** + * Walk the extension's relative-import graph starting at `entryRealPath`, + * returning the realpath of every reachable source module. Only relative + * specifiers (`./`, `../`) are followed — bare and absolute imports are left to + * Bun's native resolver — so the set is exactly the extension's own source, + * wherever it physically lives (a `../src` sibling, a symlinked sub-tree, …). + * This mirrors the module set the old temp-dir mirror tracked, minus the copy. + */ +async function collectExtensionModules(entryRealPath: string): Promise> { + const modules = new Set(); + const queue = [entryRealPath]; + while (queue.length > 0) { + const file = queue.pop(); + if (!file || modules.has(file)) { continue; } - - const resolved = Bun.resolveSync(specifier, path.dirname(importerPath)); - const mirrored = await mirrorLegacyPiFile(resolved, state); - replacements.set(specifier, toImportSpecifier(mirrored)); + let source: string; + try { + source = await Bun.file(file).text(); + } catch { + continue; + } + modules.add(file); + const dir = path.dirname(file); + for (const match of source.matchAll(RELATIVE_IMPORT_SPECIFIER_REGEX)) { + try { + const resolved = await realpathOrSelf(Bun.resolveSync(match[1], dir)); + if (!modules.has(resolved)) { + queue.push(resolved); + } + } catch { + // Unresolvable relative import (e.g. a type-only path); skip it. + } + } } + return modules; +} - if (replacements.size === 0) { - return source; +/** + * Install a `Bun.plugin()` `onLoad` hook scoped to exactly the modules in an + * extension's relative-import graph, so their legacy `@(scope)/pi-*` and bare + * `@sinclair/typebox` imports are rewritten at load time. A runtime `onLoad` + * cannot fall through (Bun requires a result object), so the filter is an + * exact-path alternation of the graph's realpaths — it never matches the host, + * other extensions, `node_modules` deps, or unrelated project source. + */ +async function ensureExtensionGraphHook(entryRealPath: string): Promise { + if (hookedExtensionEntries.has(entryRealPath)) { + return; } + hookedExtensionEntries.add(entryRealPath); - return source.replace(STATIC_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => { - const replacement = replacements.get(specifier); - return replacement ? `${prefix}${replacement}${suffix}` : match; + const modules = await collectExtensionModules(entryRealPath); + const alternation = [...modules].map(escapeRegExp).join("|"); + const filter = new RegExp(`^(?:${alternation})$`); + Bun.plugin({ + name: `omp:legacy-pi-ext:${Bun.hash(entryRealPath).toString(36)}`, + setup(build) { + build.onLoad({ filter, namespace: "file" }, async args => { + // Re-read on every load so a `?mtime` reload picks up edited source. + const raw = await Bun.file(args.path).text(); + return { contents: rewriteLegacyExtensionSource(raw), loader: getLoader(args.path) }; + }); + }, }); } -async function rewriteLegacyPiImportsForRuntime( - source: string, - importerPath: string, - state: LegacyPiMirrorState, -): Promise { - const withRelativeResolved = await rewriteRelativeImportsForLegacyExtension(source, importerPath, state); - const withLegacyRemap = rewriteLegacyPiImports(withRelativeResolved); - return rewriteBareImportsForLegacyExtension(withLegacyRemap, importerPath); -} - -async function mirrorLegacyPiFile(sourcePath: string, state: LegacyPiMirrorState): Promise { - const resolvedPath = path.resolve(sourcePath); - const cached = state.seen.get(resolvedPath); - if (cached) { - return cached; - } - - const mirrorPath = getMirrorPath(resolvedPath, state); - state.seen.set(resolvedPath, mirrorPath); - - const raw = await Bun.file(resolvedPath).text(); - const rewritten = await rewriteLegacyPiImportsForRuntime(raw, resolvedPath, state); - await Bun.write(mirrorPath, rewritten); - return mirrorPath; -} - +/** + * Load a legacy Pi extension module from its real on-disk location. + * + * The extension runs in place, so its `import.meta.url` is the real source file + * and `__dirname`-relative `readFileSync` asset loads (HTML/CSS bundled next to + * the entry) resolve exactly as they do under the original Pi runtime — no + * temp-directory mirroring and no asset copying. An `onLoad` hook scoped to the + * entry's relative-import graph rewrites only the legacy `@(scope)/pi-*` and + * `@sinclair/typebox` imports in the extension's own source; everything else + * resolves natively. + */ export async function loadLegacyPiModule(resolvedPath: string): Promise { - const root = path.join(os.tmpdir(), "omp-legacy-pi-file", `entry-${Bun.hash(resolvedPath).toString(36)}`); - await fs.rm(root, { recursive: true, force: true }); - const state: LegacyPiMirrorState = { root, seen: new Map() }; - const mirroredEntry = await mirrorLegacyPiFile(resolvedPath, state); - return import(`${toImportSpecifier(mirroredEntry)}?mtime=${Date.now()}`); + // Bun reports the realpath of a loaded module to `onLoad` and exposes it as + // `import.meta.url`. Resolve symlinks here too (macOS `/var`→`/private/var`, + // `bun link`/pnpm installs) so the rewrite filter matches the path Bun + // actually hands the hook. + const entryRealPath = await realpathOrSelf(path.resolve(resolvedPath)); + await ensureExtensionGraphHook(entryRealPath); + // `?mtime` busts Bun's module cache so repeat loads pick up edited source. + return import(`${toImportSpecifier(entryRealPath)}?mtime=${Date.now()}`); } function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" { @@ -396,36 +409,7 @@ export function installLegacyPiSpecifierShim(): void { name: "omp:legacy-pi-shim", setup(build) { build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier); - build.onResolve( - { filter: LEGACY_PI_SPECIFIER_FILTER, namespace: LEGACY_PI_FILE_NAMESPACE }, - resolveLegacyPiSpecifier, - ); - build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier); - build.onResolve( - { filter: TYPEBOX_SPECIFIER_FILTER, namespace: LEGACY_PI_FILE_NAMESPACE }, - resolveTypeBoxSpecifier, - ); - - build.onResolve({ filter: /^omp-legacy-pi-file:/, namespace: "file" }, args => ({ - path: args.path.slice(LEGACY_PI_FILE_PREFIX.length), - namespace: LEGACY_PI_FILE_NAMESPACE, - })); - - build.onResolve({ filter: /^(?:\.{1,2}\/|\/)/, namespace: LEGACY_PI_FILE_NAMESPACE }, args => ({ - path: args.path.startsWith("/") ? args.path : Bun.resolveSync(args.path, path.dirname(args.importer)), - namespace: LEGACY_PI_FILE_NAMESPACE, - })); - - build.onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: LEGACY_PI_FILE_NAMESPACE }, async args => { - const raw = await Bun.file(args.path).text(); - const withLegacyRemap = rewriteLegacyPiImports(raw); - const withBareResolved = rewriteBareImportsForLegacyExtension(withLegacyRemap, args.path); - return { - contents: withBareResolved, - loader: getLoader(args.path), - }; - }); }, }); } diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 6db5a72fc..1725d7492 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -273,7 +273,6 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise compareSkillOrder(a.name, a.filePath, b.name, b.filePath)); - return { skills, warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings], diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f65558a42..e7ca42a38 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -21,6 +21,7 @@ import { VERSION, } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; +import { reset as resetCapabilities } from "./capability"; import type { Args } from "./cli/args"; import { applyExtensionFlags, type ExtensionFlagSink } from "./cli/extension-flags"; import { processFileArguments } from "./cli/file-processor"; @@ -91,15 +92,11 @@ async function checkForNewVersion(currentVersion: string): Promise { if (process.stdin.isTTY !== false) return undefined; try { @@ -312,15 +325,19 @@ async function runInteractiveMode( } } -async function promptForkSession(session: SessionInfo): Promise { +type ForkSessionPromptResult = "accepted" | "declined" | "unavailable"; + +type ForkSessionPrompt = (session: SessionInfo) => Promise; + +async function promptForkSession(session: SessionInfo): Promise { if (!process.stdin.isTTY) { - return false; + return "unavailable"; } const message = `Session found in different project: ${session.cwd}. Fork into current directory? [y/N] `; const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = (await rl.question(message)).trim().toLowerCase(); - return answer === "y" || answer === "yes"; + return answer === "y" || answer === "yes" ? "accepted" : "declined"; } finally { rl.close(); } @@ -366,10 +383,12 @@ async function flushChangelogVersion(): Promise { } } -async function createSessionManager( +/** Resolves CLI session flags into an existing, forked, in-memory, or cancelled session manager. */ +export async function createSessionManager( parsed: Args, cwd: string, activeSettings: Settings = settings, + askToForkSession: ForkSessionPrompt = promptForkSession, ): Promise { if (parsed.fork) { if (parsed.noSession) { @@ -402,9 +421,17 @@ async function createSessionManager( const normalizedCwd = normalizePathForComparison(cwd); const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd); if (normalizedCwd !== normalizedMatchCwd) { - const shouldFork = await promptForkSession(match.session); - if (!shouldFork) { - throw new Error(`Session "${sessionArg}" is in another project (${match.session.cwd}).`); + const forkPromptResult = await askToForkSession(match.session); + if (forkPromptResult === "unavailable") { + throw new Error( + `Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`, + ); + } + if (forkPromptResult === "declined") { + // User declined the cross-project fork prompt. Caller distinguishes + // this cancellation from the "default new session" undefined return + // by checking `typeof parsed.resume === "string"`. + return undefined; } return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir); } @@ -776,15 +803,17 @@ export async function runRootCommand( } } - const cwd = getProjectDir(); + let cwd = getProjectDir(); const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd })); if (parsedArgs.approvalMode) { // Runtime override (not persisted): every settings.get("tools.approvalMode") downstream // sees this value. The wrapper still honours --auto-approve / --yolo on top of it. settingsInstance.override("tools.approvalMode", parsedArgs.approvalMode); } - if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") { + if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui") { applyRpcDefaultSettingOverrides(settingsInstance); + } else if (parsedArgs.mode === "acp") { + applyAcpDefaultSettingOverrides(settingsInstance); } if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") { Bun.env.PI_NO_PTY = "1"; @@ -812,6 +841,11 @@ export async function runRootCommand( }); } + // Apply --hide-thinking CLI flag (ephemeral, not persisted) + if (parsedArgs.hideThinking) { + settingsInstance.override("hideThinkingBlock", true); + } + await logger.time( "initTheme:final", initTheme, @@ -846,19 +880,53 @@ export async function runRootCommand( settingsInstance, ); + // User declined the cross-project fork prompt — exit cleanly with a friendly + // message rather than letting the decline bubble up as an uncaught exception + // (see issue #1668). + if (typeof parsedArgs.resume === "string" && !sessionManager) { + process.stdout.write(`${chalk.dim("Resume cancelled: session is in another project.")}\n`); + return; + } + // Handle --resume (no value): show session picker if (parsedArgs.resume === true && !parsedArgs.fork) { - const sessions = await logger.time("SessionManager.list", SessionManager.list, cwd, parsedArgs.sessionDir); - if (sessions.length === 0) { - process.stdout.write(`${chalk.dim("No sessions found")}\n`); - return; + const folderSessions = await logger.time("SessionManager.list", SessionManager.list, cwd, parsedArgs.sessionDir); + let preloadedAllSessions: SessionInfo[] | undefined; + let startInAllScope = false; + if (folderSessions.length === 0) { + // Nothing in the current folder — fall back to a global scan so the + // picker can still open in all-projects scope instead of dead-ending. + preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll); + if (preloadedAllSessions.length === 0) { + process.stdout.write(`${chalk.dim("No sessions found")}\n`); + return; + } + startInAllScope = true; } - const selectedPath = await logger.time("selectSession", selectSession, sessions); - if (!selectedPath) { + const selected = await logger.time("selectSession", selectSession, folderSessions, { + allSessions: preloadedAllSessions, + startInAllScope, + }); + if (!selected) { process.stdout.write(`${chalk.dim("No session selected")}\n`); return; } - sessionManager = await SessionManager.open(selectedPath); + // Resuming a session from another project: switch the process into that + // project's directory and refresh cwd-derived caches before the session is + // built, so settings discovery, plugins, and capabilities all scope to it. + if (selected.cwd && normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir())) { + // Let the original (launch-cwd) plugin-root preload settle first so its + // late resolution can't clobber the re-warm we trigger below. + await pluginPreloadPromise.catch(() => {}); + setProjectDir(selected.cwd); + clearPluginRootsAndCaches(); + resetCapabilities(); + cwd = getProjectDir(); + // Re-scope project settings (.claude/settings.yml etc.) to the resumed + // project in place so the session is built with its configuration. + await settingsInstance.reloadForCwd(cwd); + } + sessionManager = await SessionManager.open(selected.path); } await pluginPreloadPromise; diff --git a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts index 725e86fae..bbad196c3 100644 --- a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts +++ b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts @@ -69,6 +69,16 @@ interface CommandContainer { command?: unknown; } +interface EvalCellContainer { + cells?: unknown; +} + +interface EvalCellLike { + language?: unknown; + title?: unknown; + code?: unknown; +} + interface PatternContainer { pattern?: unknown; } @@ -435,11 +445,43 @@ function getToolExecutionEndArgs( } function buildToolStartContent(toolName: string, args: unknown): ToolCallContent[] { - if (!isCommandToolName(toolName)) { - return []; + const text = buildToolStartText(toolName, args); + return text ? [textToolCallContent(text)] : []; +} + +function buildToolStartText(toolName: string, args: unknown): string | undefined { + if (isCommandToolName(toolName)) { + const command = extractStringProperty(args, "command"); + return command ? limitText(`$ ${command}`) : undefined; } - const command = extractStringProperty(args, "command"); - return command ? [textToolCallContent(`$ ${command}`)] : []; + if (toolName === "eval") { + return buildEvalStartText(args); + } + return undefined; +} + +function buildEvalStartText(args: unknown): string | undefined { + if (typeof args !== "object" || args === null || Array.isArray(args)) { + return undefined; + } + const cells = (args as EvalCellContainer).cells; + if (!Array.isArray(cells) || cells.length === 0) { + return undefined; + } + const lines: string[] = []; + for (const cell of cells) { + if (typeof cell !== "object" || cell === null || Array.isArray(cell)) { + continue; + } + const language = extractStringProperty(cell, "language") ?? "?"; + const title = extractStringProperty(cell, "title"); + const code = extractStringProperty(cell, "code"); + if (!code) { + continue; + } + lines.push(title ? `[${language}] ${title}` : `[${language}]`, code); + } + return lines.length > 0 ? limitText(lines.join("\n")) : undefined; } function mergeToolUpdateContent(startContent: ToolCallContent[], resultContent: ToolCallContent[]): ToolCallContent[] { @@ -465,6 +507,14 @@ function isCommandToolName(toolName: string): boolean { } function buildToolTitle(toolName: string, args: unknown, intent: string | undefined): string { + if (isCommandToolName(toolName)) { + const commandText = buildToolStartText(toolName, args); + if (commandText) return commandText; + } + if (toolName === "eval") { + const evalText = buildEvalStartText(args); + if (evalText) return evalText; + } const trimmedIntent = intent?.trim(); if (trimmedIntent) { return trimmedIntent; diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 30445463f..046cd6e16 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -19,6 +19,7 @@ type ConfigurableEditorAction = Extract< | "app.history.search" | "app.message.dequeue" | "app.clipboard.pasteImage" + | "app.clipboard.pasteTextRaw" | "app.clipboard.copyPrompt" >; @@ -38,6 +39,7 @@ const DEFAULT_ACTION_KEYS: Record = { "app.history.search": ["ctrl+r"], "app.message.dequeue": ["alt+up"], "app.clipboard.pasteImage": ["ctrl+v"], + "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"], "app.clipboard.copyPrompt": ["alt+shift+c"], }; @@ -65,6 +67,8 @@ export class CustomEditor extends Editor { onCopyPrompt?: () => void; /** Called when the configured image-paste shortcut is pressed. */ onPasteImage?: () => Promise; + /** Called when the configured raw text-paste shortcut is pressed. */ + onPasteTextRaw?: () => void; /** Called when the configured dequeue shortcut is pressed. */ onDequeue?: () => void; /** Called when Caps Lock is pressed. */ @@ -124,6 +128,12 @@ export class CustomEditor extends Editor { return; } + // Intercept configured raw text paste (fires and handles result) + if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) { + this.onPasteTextRaw(); + return; + } + // Intercept configured external editor shortcut if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) { this.onExternalEditor(); diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 924a7209f..b1d87a2ba 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -68,6 +68,9 @@ export interface HookSelectorOptions { onExternalEditor?: () => void; helpText?: string; slider?: HookSelectorSlider; + /** Indices into the original options that cannot be selected: they render + * dimmed, are skipped during navigation, and reject enter/timeout. */ + disabledIndices?: readonly number[]; } export interface HookSelectorOption { @@ -127,11 +130,16 @@ class OutlinedList extends Container { } } +/** A filtered option paired with its index into the original options array, so + * disabled-index lookups survive fuzzy filtering and reordering. */ +type FilteredOption = { option: HookSelectorOption; index: number }; + export class HookSelectorComponent extends Container { #options: HookSelectorOption[]; - #filteredOptions: HookSelectorOption[]; + #filteredOptions: FilteredOption[]; #searchQuery = ""; #selectedIndex: number; + #disabledIndices: Set; #maxVisible: number; #listContainer: Container | undefined; #outlinedList: OutlinedList | undefined; @@ -157,8 +165,13 @@ export class HookSelectorComponent extends Container { super(); this.#options = options.map(normalizeHookSelectorOption); - this.#filteredOptions = this.#options; - this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1); + this.#filteredOptions = this.#options.map((option, index) => ({ option, index })); + this.#disabledIndices = new Set( + (opts?.disabledIndices ?? []).filter( + index => Number.isInteger(index) && index >= 0 && index < this.#options.length, + ), + ); + this.#selectedIndex = this.#coerceSelectedIndex(opts?.initialIndex ?? 0); this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12); this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; @@ -191,9 +204,10 @@ export class HookSelectorComponent extends Container { s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), () => { opts?.onTimeout?.(); + // Auto-select current option on timeout (typically the first/recommended option) const selected = this.#filteredOptions[this.#selectedIndex]; - if (selected) { - this.#onSelectCallback(selected.label); + if (selected && !this.#isDisabled(selected.index)) { + this.#onSelectCallback(selected.option.label); } else { this.#onCancelCallback(); } @@ -217,14 +231,62 @@ export class HookSelectorComponent extends Container { this.#updateList(); } - #renderOptionLines(option: HookSelectorOption, isSelected: boolean, mdTheme: MarkdownTheme): string[] { - const label = isSelected - ? renderInlineMarkdown(option.label, mdTheme, t => theme.fg("accent", t)) - : renderInlineMarkdown(option.label, mdTheme, t => theme.fg("text", t)); - const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " "; + #isDisabled(index: number): boolean { + return this.#disabledIndices.has(index); + } + + /** Clamp `index` into range, then walk forward (and finally backward) to the + * nearest enabled option so the cursor never lands on a disabled row. */ + #coerceSelectedIndex(index: number): number { + if (this.#filteredOptions.length === 0) return -1; + const maxIndex = this.#filteredOptions.length - 1; + const clamped = Math.max(0, Math.min(index, maxIndex)); + const clampedOption = this.#filteredOptions[clamped]; + if (clampedOption && !this.#isDisabled(clampedOption.index)) return clamped; + for (let i = clamped + 1; i <= maxIndex; i++) { + const option = this.#filteredOptions[i]; + if (option && !this.#isDisabled(option.index)) return i; + } + for (let i = clamped - 1; i >= 0; i--) { + const option = this.#filteredOptions[i]; + if (option && !this.#isDisabled(option.index)) return i; + } + return clamped; + } + + /** Move the cursor by `delta`, skipping disabled rows, stopping at the first + * enabled option reached or at the list edge. */ + #moveSelection(delta: number): void { + if (this.#filteredOptions.length === 0) return; + const maxIndex = this.#filteredOptions.length - 1; + let index = this.#selectedIndex; + while (true) { + const next = Math.max(0, Math.min(index + delta, maxIndex)); + if (next === index) return; + index = next; + const option = this.#filteredOptions[index]; + if (option && !this.#isDisabled(option.index)) { + this.#selectedIndex = index; + this.#updateList(); + return; + } + } + } + + #renderOptionLines( + option: HookSelectorOption, + isSelected: boolean, + isDisabled: boolean, + mdTheme: MarkdownTheme, + ): string[] { + const textColor = isDisabled ? "dim" : isSelected ? "accent" : "text"; + const prefixColor = isDisabled ? "dim" : "accent"; + const label = renderInlineMarkdown(option.label, mdTheme, t => theme.fg(textColor, t)); + const prefix = isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " "; const lines = [prefix + label]; if (option.description) { - const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg("muted", t)); + const descriptionColor = isDisabled ? "dim" : "muted"; + const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t)); lines.push(` ${description}`); } return lines; @@ -250,7 +312,7 @@ export class HookSelectorComponent extends Container { ): number { if (renderWidth === undefined) return option.description ? 2 : 1; let rows = 0; - for (const line of this.#renderOptionLines(option, isSelected, mdTheme)) { + for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme)) { rows += this.#renderedLineRowCount(line, renderWidth); } return rows; @@ -276,12 +338,12 @@ export class HookSelectorComponent extends Container { const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1)); let startIndex = selectedIndex; let endIndex = selectedIndex + 1; - let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!, renderWidth, true, mdTheme); + let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!.option, renderWidth, true, mdTheme); let beforeRows = 0; const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2)); while (startIndex > 0) { - const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme); + const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme); if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break; startIndex--; beforeRows += cost; @@ -289,14 +351,14 @@ export class HookSelectorComponent extends Container { } while (endIndex < total) { - const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!, renderWidth, false, mdTheme); + const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!.option, renderWidth, false, mdTheme); if (rows + cost > rowBudget) break; endIndex++; rows += cost; } while (startIndex > 0) { - const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme); + const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme); if (rows + cost > rowBudget) break; startIndex--; rows += cost; @@ -312,9 +374,10 @@ export class HookSelectorComponent extends Container { const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme); for (let i = startIndex; i < endIndex; i++) { - const option = this.#filteredOptions[i]; - if (option === undefined) continue; - lines.push(...this.#renderOptionLines(option, i === this.#selectedIndex, mdTheme)); + const filtered = this.#filteredOptions[i]; + if (filtered === undefined) continue; + const isSelected = i === this.#selectedIndex; + lines.push(...this.#renderOptionLines(filtered.option, isSelected, this.#isDisabled(filtered.index), mdTheme)); } if (total === 0) { @@ -389,10 +452,11 @@ export class HookSelectorComponent extends Container { #setSearchQuery(query: string): void { this.#searchQuery = query; + const indexedOptions = this.#options.map((option, index) => ({ option, index })); this.#filteredOptions = query.trim() - ? fuzzyFilter(this.#options, query, option => `${option.label} ${option.description ?? ""}`) - : this.#options; - this.#selectedIndex = 0; + ? fuzzyFilter(indexedOptions, query, item => `${item.option.label} ${item.option.description ?? ""}`) + : indexedOptions; + this.#selectedIndex = this.#coerceSelectedIndex(0); this.#updateList(); } @@ -429,18 +493,12 @@ export class HookSelectorComponent extends Container { } if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) { - if (this.#filteredOptions.length > 0) { - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); - this.#updateList(); - } + this.#moveSelection(-1); } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) { - if (this.#filteredOptions.length > 0) { - this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + 1); - this.#updateList(); - } + this.#moveSelection(1); } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selected = this.#filteredOptions[this.#selectedIndex]; - if (selected) this.#onSelectCallback(selected.label); + if (selected && !this.#isDisabled(selected.index)) this.#onSelectCallback(selected.option.label); } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) { if (this.#slider) this.#moveSlider(-1); else this.#onLeftCallback?.(); diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 8ec246621..3e302be10 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -67,8 +67,14 @@ export class OAuthSelectorComponent extends Container { this.#validationGeneration += 1; this.#stopSpinner(); } + #hasSelectableAuth(providerId: string): boolean { + return this.#mode === "logout" ? this.#authStorage.has(providerId) : this.#authStorage.hasAuth(providerId); + } + #loadProviders(): void { - this.#allProviders = getOAuthProviders(); + const providers = getOAuthProviders(); + this.#allProviders = + this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers; this.#filteredProviders = this.#allProviders; } @@ -79,7 +85,7 @@ export class OAuthSelectorComponent extends Container { let pending = 0; for (const provider of this.#allProviders) { - if (!this.#authStorage.hasAuth(provider.id)) { + if (!this.#hasSelectableAuth(provider.id)) { this.#authState.delete(provider.id); continue; } @@ -145,8 +151,9 @@ export class OAuthSelectorComponent extends Container { if (state === "valid") { return theme.fg("success", ` ${theme.status.success} logged in`); } - return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : ""; + return this.#hasSelectableAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : ""; } + #isSearchEnabled(): boolean { return this.#allProviders.length > OAUTH_SELECTOR_MAX_VISIBLE; } @@ -167,7 +174,7 @@ export class OAuthSelectorComponent extends Container { #getProviderSearchText(provider: OAuthProviderInfo): string { let text = `${provider.name} ${provider.id}`; - if (this.#authStorage.hasAuth(provider.id)) { + if (this.#hasSelectableAuth(provider.id)) { text += " logged in authenticated"; } if (!provider.available) { @@ -240,13 +247,12 @@ export class OAuthSelectorComponent extends Container { this.#listContainer.addChild(new TruncatedText(this.#renderStatusLine(total), 0, 0)); } - // Show "no providers" if empty if (total === 0) { const message = this.#allProviders.length === 0 ? this.#mode === "login" ? "No OAuth providers available" - : "No OAuth providers logged in. Use /login first." + : "No stored provider credentials to log out" : "No matching providers"; this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index ba00c7367..7dc036d51 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -15,9 +15,52 @@ import { formatBytes } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { SessionInfo } from "../../session/session-manager"; +import { shortenPath } from "../../tools/render-utils"; import { DynamicBorder } from "./dynamic-border"; import { HookSelectorComponent } from "./hook-selector"; +/** Returns the IDs of sessions whose recorded prompts match a query, best first. */ +export type SessionHistoryMatcher = (query: string) => string[]; + +/** + * Combine fuzzy session matches with prompt-history matches for ranking, using + * both signals rather than replacing one with the other. + * + * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first). + * - `historyIds` are session IDs whose recorded prompts matched the query, + * ordered by history relevance (best first); duplicates are tolerated. + * + * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then + * fuzzy-only matches, then history-only matches (by history order). A fuzzy match + * is never dropped, and history matches not present in `allSessions` (e.g. deleted + * or out-of-scope sessions) are ignored since they cannot be resumed from here. + */ +export function mergeSessionRanking( + allSessions: SessionInfo[], + fuzzy: SessionInfo[], + historyIds: string[], +): SessionInfo[] { + const historyRank = new Map(); + historyIds.forEach((id, index) => { + if (!historyRank.has(id)) historyRank.set(id, index); + }); + if (historyRank.size === 0) return fuzzy; + + const both: SessionInfo[] = []; + const fuzzyOnly: SessionInfo[] = []; + const fuzzyPaths = new Set(); + for (const session of fuzzy) { + fuzzyPaths.add(session.path); + (historyRank.has(session.id) ? both : fuzzyOnly).push(session); + } + + const historyOnly = allSessions + .filter(session => historyRank.has(session.id) && !fuzzyPaths.has(session.path)) + .sort((a, b) => (historyRank.get(a.id) ?? 0) - (historyRank.get(b.id) ?? 0)); + + return [...both, ...fuzzyOnly, ...historyOnly]; +} + /** * Custom session list component with multi-line items and search */ @@ -25,33 +68,44 @@ class SessionList implements Component { #filteredSessions: SessionInfo[] = []; #selectedIndex: number = 0; readonly #searchInput: Input; - onSelect?: (sessionPath: string) => void; + onSelect?: (session: SessionInfo) => void; onCancel?: () => void; onExit: () => void = () => {}; + onToggleScope?: () => void; #maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) onDeleteRequest?: (session: SessionInfo) => void; - constructor( - private readonly allSessions: SessionInfo[], - private readonly showCwd = false, - ) { - this.#filteredSessions = allSessions; + #allSessions: SessionInfo[]; + #showCwd: boolean; + readonly #historyMatcher?: SessionHistoryMatcher; + + constructor(sessions: SessionInfo[], showCwd = false, historyMatcher?: SessionHistoryMatcher) { + this.#allSessions = sessions; + this.#showCwd = showCwd; + this.#historyMatcher = historyMatcher; + this.#filteredSessions = sessions; this.#searchInput = new Input(); // Handle Enter in search input - select current item this.#searchInput.onSubmit = () => { - if (this.#filteredSessions[this.#selectedIndex]) { - const selected = this.#filteredSessions[this.#selectedIndex]; - if (this.onSelect) { - this.onSelect(selected.path); - } + const selected = this.#filteredSessions[this.#selectedIndex]; + if (selected) { + this.onSelect?.(selected); } }; } + /** Replace the visible dataset, e.g. when toggling folder/all-projects scope. */ + setSessions(sessions: SessionInfo[], showCwd: boolean): void { + this.#allSessions = sessions; + this.#showCwd = showCwd; + this.#selectedIndex = 0; + this.#filterSessions(this.#searchInput.getValue()); + } + #filterSessions(query: string): void { - this.#filteredSessions = fuzzyFilter(this.allSessions, query, session => { + const fuzzy = fuzzyFilter(this.#allSessions, query, session => { const parts = [ session.id, session.title ?? "", @@ -62,13 +116,28 @@ class SessionList implements Component { ]; return parts.filter(Boolean).join(" "); }); + this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy); this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1)); } + /** + * Augment fuzzy results with prompt-history matches without replacing them. + * The session-list corpus only sees the first 4KB of each session, so a prompt + * typed deep into a long session is invisible to fuzzy search; `historyMatcher` + * recovers those via `history.db`. + */ + #mergeHistoryMatches(query: string, fuzzy: SessionInfo[]): SessionInfo[] { + const trimmed = query.trim(); + if (!trimmed || !this.#historyMatcher) return fuzzy; + const historyIds = this.#historyMatcher(trimmed); + if (historyIds.length === 0) return fuzzy; + return mergeSessionRanking(this.#allSessions, fuzzy, historyIds); + } + removeSession(sessionPath: string): void { - const index = this.allSessions.findIndex(s => s.path === sessionPath); + const index = this.#allSessions.findIndex(s => s.path === sessionPath); if (index === -1) return; - this.allSessions.splice(index, 1); + this.#allSessions.splice(index, 1); // Re-filter to update filteredSessions this.#filterSessions(this.#searchInput.getValue()); // Adjust selectedIndex if we deleted the last item or beyond @@ -89,7 +158,7 @@ class SessionList implements Component { lines.push(""); // Blank line after search if (this.#filteredSessions.length === 0) { - if (this.showCwd) { + if (this.#showCwd) { // "All" scope - no sessions anywhere that match filter lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width)); } else { @@ -158,9 +227,12 @@ class SessionList implements Component { lines.push(messageLine); } - // Metadata line: date + file size + // Metadata line: date + file size (+ project dir in all-projects scope) const modified = formatDate(session.modified); - const metadata = ` ${modified} ${theme.sep.dot} ${formatBytes(session.size)}`; + let metadata = ` ${modified} ${theme.sep.dot} ${formatBytes(session.size)}`; + if (this.#showCwd && session.cwd) { + metadata += ` ${theme.sep.dot} ${shortenPath(session.cwd)}`; + } const metadataLine = theme.fg("dim", truncateToWidth(metadata, width)); lines.push(metadataLine); @@ -176,7 +248,12 @@ class SessionList implements Component { // Add keybinding hint lines.push(""); - lines.push(theme.fg("muted", " [Del to delete, Enter to select, Esc to cancel]")); + lines.push( + theme.fg( + "muted", + ` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`, + ), + ); return lines; } @@ -215,7 +292,7 @@ class SessionList implements Component { if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selected = this.#filteredSessions[this.#selectedIndex]; if (selected && this.onSelect) { - this.onSelect(selected.path); + this.onSelect(selected); } return; } @@ -231,12 +308,28 @@ class SessionList implements Component { this.onExit(); return; } + // Tab - toggle folder / all-projects scope + if (matchesKey(keyData, "tab")) { + this.onToggleScope?.(); + return; + } // Pass everything else to search input this.#searchInput.handleInput(keyData); this.#filterSessions(this.#searchInput.getValue()); } } +export interface SessionSelectorOptions { + onDelete?: (session: SessionInfo) => Promise; + historyMatcher?: SessionHistoryMatcher; + /** Loads sessions across all projects for the all-projects scope toggle (Tab). */ + loadAllSessions?: () => Promise; + /** Preloaded all-projects list; cached so the first Tab toggle is instant. */ + allSessions?: SessionInfo[]; + /** Open directly in all-projects scope (e.g. the current folder has no sessions). */ + startInAllScope?: boolean; +} + /** * Component that renders a session selector with optional confirmation dialog */ @@ -244,35 +337,55 @@ export class SessionSelectorComponent extends Container { #sessionList: SessionList; #confirmationDialog: HookSelectorComponent | null = null; #messageContainer: Container; + #headerText: Text; #onDelete?: (session: SessionInfo) => Promise; #onRequestRender?: () => void; + readonly #loadAllSessions?: () => Promise; + #folderSessions: SessionInfo[]; + #globalSessions: SessionInfo[] | null = null; + #scope: "folder" | "all" = "folder"; + #toggling = false; constructor( sessions: SessionInfo[], - onSelect: (sessionPath: string) => void, + onSelect: (session: SessionInfo) => void, onCancel: () => void, onExit: () => void, - onDelete?: (session: SessionInfo) => Promise, + options: SessionSelectorOptions = {}, ) { super(); this.#messageContainer = new Container(); - this.#onDelete = onDelete; + this.#onDelete = options.onDelete; + this.#loadAllSessions = options.loadAllSessions; + this.#folderSessions = sessions; + this.#globalSessions = options.allSessions ?? null; + // Open in all-projects scope when asked and we already have that list + // (e.g. the current folder has no sessions to show). + const startAll = options.startInAllScope === true && this.#globalSessions !== null; + this.#scope = startAll ? "all" : "folder"; + const initialSessions = startAll ? this.#globalSessions! : sessions; // Add header this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Resume Session"), 1, 0)); + this.#headerText = new Text(this.#headerLabel(), 1, 0); + this.addChild(this.#headerText); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.#messageContainer); // Create session list - this.#sessionList = new SessionList(sessions); + this.#sessionList = new SessionList(initialSessions, startAll, options.historyMatcher); this.#sessionList.onSelect = onSelect; this.#sessionList.onCancel = onCancel; this.#sessionList.onExit = onExit; this.#sessionList.onDeleteRequest = (session: SessionInfo) => { this.#showDeleteConfirmation(session); }; + if (this.#loadAllSessions || this.#globalSessions) { + this.#sessionList.onToggleScope = () => { + void this.#toggleScope(); + }; + } this.addChild(this.#sessionList); // Add bottom border @@ -280,6 +393,48 @@ export class SessionSelectorComponent extends Container { this.addChild(new DynamicBorder()); } + #headerLabel(): string { + const scopeLabel = this.#scope === "all" ? "all projects" : "current folder"; + return `${theme.bold("Resume Session")} ${theme.fg("muted", `(${scopeLabel})`)}`; + } + + /** + * Toggle between current-folder and all-projects scope. The global list is + * loaded lazily on first switch and cached, so the common folder-scope path + * never pays for the cross-project scan. + */ + async #toggleScope(): Promise { + if (this.#toggling || this.#confirmationDialog) return; + if (this.#scope === "folder") { + let global = this.#globalSessions; + if (!global) { + if (!this.#loadAllSessions) return; + this.#toggling = true; + this.#messageContainer.clear(); + this.#messageContainer.addChild(new Text(theme.fg("muted", " Loading all projects…"), 1, 0)); + this.#onRequestRender?.(); + try { + global = await this.#loadAllSessions(); + } catch (err) { + this.#showError(err instanceof Error ? err.message : String(err)); + this.#toggling = false; + this.#onRequestRender?.(); + return; + } + this.#globalSessions = global; + this.#messageContainer.clear(); + this.#toggling = false; + } + this.#scope = "all"; + this.#sessionList.setSessions(global, true); + } else { + this.#scope = "folder"; + this.#sessionList.setSessions(this.#folderSessions, false); + } + this.#headerText.setText(this.#headerLabel()); + this.#onRequestRender?.(); + } + setOnRequestRender(callback: () => void): void { this.#onRequestRender = callback; } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 4e7f81587..b77c5cb8c 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -180,6 +180,8 @@ export class ToolExecutionComponent extends Container { #editDiffPreview?: PerFileDiffPreview[]; #editDiffAbort?: AbortController; #editDiffLastArgsKey?: string; + // Latest in-flight streaming diff recompute, captured so it can be awaited. + #editDiffInFlight?: Promise; // Cached converted images for Kitty protocol (which requires PNG), keyed by index #convertedImages: Map = new Map(); // Spinner animation for partial task results @@ -239,7 +241,7 @@ export class ToolExecutionComponent extends Container { this.#editMode = resolveEditModeForTool(toolName, tool); this.#updateDisplay(); - void this.#runPreviewDiff(); + this.#editDiffInFlight = this.#runPreviewDiff(); } updateArgs(args: any, _toolCallId?: string): void { @@ -250,7 +252,7 @@ export class ToolExecutionComponent extends Container { if (args === this.#args) return; this.#args = args; this.#updateSpinnerAnimation(); - void this.#runPreviewDiff(); + this.#editDiffInFlight = this.#runPreviewDiff(); this.#updateDisplay(); } @@ -261,7 +263,18 @@ export class ToolExecutionComponent extends Container { setArgsComplete(_toolCallId?: string): void { this.#argsComplete = true; this.#updateSpinnerAnimation(); - void this.#runPreviewDiff(); + this.#editDiffInFlight = this.#runPreviewDiff(); + } + + /** + * Await the streaming diff recompute kicked off by the most recent + * `updateArgs`/`setArgsComplete`. The recompute reads the file and re-runs the + * whole-file Myers diff off the render path, signalling completion only via a + * throttled `requestRender`. Tests await this to sample a *settled* preview + * deterministically instead of racing the spinner's render ticks. + */ + async whenPreviewSettled(): Promise { + await this.#editDiffInFlight; } async #runPreviewDiff(): Promise { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 96c1526df..60552ecb5 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -11,10 +11,8 @@ import { type UsageReport, } from "@oh-my-pi/pi-ai"; import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; -import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; +import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { reset as resetCapabilities } from "../../capability"; -import { clearClaudePluginRootsCache } from "../../discovery/helpers"; import { loadCustomShare } from "../../export/custom-share"; import type { CompactOptions } from "../../extensibility/extensions/types"; import { @@ -993,14 +991,7 @@ export class CommandController { try { await this.ctx.sessionManager.flush(); await this.ctx.sessionManager.moveTo(resolvedPath); - setProjectDir(resolvedPath); - clearClaudePluginRootsCache(); // re-warms preloadedPluginRoots with new project dir (async) - resetCapabilities(); - await this.ctx.refreshSlashCommandState(resolvedPath); - await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); - - this.ctx.statusLine.invalidate(); - this.ctx.updateEditorTopBorder(); + await this.ctx.applyCwdChange(resolvedPath); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 7d2ea98fb..843047249 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -22,7 +22,7 @@ import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector"; import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme"; -import type { InteractiveModeContext } from "../../modes/types"; +import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types"; import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator"; const MAX_WIDGET_LINES = 10; @@ -583,7 +583,7 @@ export class ExtensionUiController { showHookSelector( title: string, options: ExtensionUISelectItem[], - dialogOptions?: ExtensionUIDialogOptions, + dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { const { promise, finish, attachAbort } = this.#createHookDialogState( @@ -624,6 +624,7 @@ export class ExtensionUiController { onTimeout: dialogOptions?.onTimeout, tui: this.ctx.ui, outline: dialogOptions?.outline, + disabledIndices: dialogOptions?.disabledIndices, maxVisible, slider: extra?.slider, }, diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 123bce98c..841b5dcf0 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -15,7 +15,7 @@ import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registr import { isTinyTitleLocalModelKey } from "../../tiny/models"; import { tinyTitleClient } from "../../tiny/title-client"; import type { TinyTitleProgressEvent } from "../../tiny/title-protocol"; -import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; +import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { ensureSupportedImageInput } from "../../utils/image-loading"; import { resizeImage } from "../../utils/image-resize"; @@ -173,6 +173,11 @@ export class InputController { this.ctx.keybindings.getKeys("app.clipboard.pasteImage"), ); this.ctx.editor.onPasteImage = () => this.handleImagePaste(); + this.ctx.editor.setActionKeys( + "app.clipboard.pasteTextRaw", + this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"), + ); + this.ctx.editor.onPasteTextRaw = () => void this.handleClipboardTextRawPaste(); this.ctx.editor.setActionKeys( "app.clipboard.copyPrompt", this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"), @@ -690,6 +695,19 @@ export class InputController { } } + async handleClipboardTextRawPaste(): Promise { + try { + const text = await readTextFromClipboard(); + if (text) { + this.ctx.editor.insertText(text); + this.ctx.ui.requestRender(); + this.ctx.showStatus("No text in clipboard to paste raw"); + } + } catch { + this.ctx.showStatus("Failed to paste raw text from clipboard"); + } + } + createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider { return createPromptActionAutocompleteProvider({ commands, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ba7676e0e..9dedf13f3 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -3,7 +3,7 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui"; import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui"; -import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils"; +import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils"; import { getRoleInfo } from "../../config/model-registry"; import { formatModelSelectorValue } from "../../config/model-resolver"; import { settings } from "../../config/settings"; @@ -36,6 +36,7 @@ import { setPreferredImageProvider, setPreferredSearchProvider, } from "../../tools"; +import { shortenPath } from "../../tools/render-utils"; import { setSessionTerminalTitle } from "../../utils/title-generator"; import { AgentDashboard } from "../components/agent-dashboard"; import { AssistantMessageComponent } from "../components/assistant-message"; @@ -719,12 +720,22 @@ export class SelectorController { this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.getSessionDir(), ); + // Current folder has no sessions: preload the global list so the picker + // can open straight into all-projects scope instead of dead-ending. + let allSessions: SessionInfo[] | undefined; + let startInAllScope = false; + if (sessions.length === 0) { + allSessions = await SessionManager.listAll(); + startInAllScope = allSessions.length > 0; + } + const historyStorage = this.ctx.historyStorage; + const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined; this.showSelector(done => { const selector = new SessionSelectorComponent( sessions, - async sessionPath => { + async (session: SessionInfo) => { done(); - await this.handleResumeSession(sessionPath); + await this.handleResumeSession(session.path); }, () => { done(); @@ -733,19 +744,25 @@ export class SelectorController { () => { void this.ctx.shutdown(); }, - async (session: SessionInfo) => { - if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) { - return false; - } - const storage = new FileSessionStorage(); - try { - await storage.deleteSessionWithArtifacts(session.path); - return true; - } catch (err) { - throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, { - cause: err, - }); - } + { + onDelete: async (session: SessionInfo) => { + if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) { + return false; + } + const storage = new FileSessionStorage(); + try { + await storage.deleteSessionWithArtifacts(session.path); + return true; + } catch (err) { + throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, { + cause: err, + }); + } + }, + historyMatcher, + loadAllSessions: () => SessionManager.listAll(), + allSessions, + startInAllScope, }, ); selector.setOnRequestRender(() => this.ctx.ui.requestRender()); @@ -801,8 +818,17 @@ export class SelectorController { async handleResumeSession(sessionPath: string): Promise { this.#clearTransientSessionUi(); - // Switch session via AgentSession (emits hook and tool session events) + const previousCwd = this.ctx.sessionManager.getCwd(); + // Switch session via AgentSession (emits hook and tool session events). The + // SessionManager adopts the resumed session's own cwd when it differs. await this.ctx.session.switchSession(sessionPath); + const newCwd = this.ctx.sessionManager.getCwd(); + const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd); + if (movedProject) { + // Resumed a session from another project: re-point the process and every + // cwd-derived cache at it before rendering. + await this.ctx.applyCwdChange(newCwd); + } this.#refreshSessionTerminalTitle(); this.ctx.updateEditorBorderColor(); @@ -810,7 +836,7 @@ export class SelectorController { this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true }); await this.ctx.reloadTodos(); - this.ctx.showStatus("Resumed session"); + this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session"); } async handleSessionDeleteCommand(): Promise { @@ -919,7 +945,15 @@ export class SelectorController { async #handleOAuthLogout(providerId: string): Promise { try { - await this.ctx.session.modelRegistry.authStorage.logout(providerId); + const authStorage = this.ctx.session.modelRegistry.authStorage; + if (!authStorage.has(providerId)) { + const source = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId); + const suffix = source ? ` Current auth comes from ${source}; remove that source to log out.` : ""; + this.ctx.showError(`Logout skipped: no stored credentials for ${providerId}.${suffix}`); + return; + } + + await authStorage.logout(providerId); await this.ctx.session.modelRegistry.refresh(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( @@ -928,6 +962,12 @@ export class SelectorController { this.ctx.chatContainer.addChild( new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0), ); + const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId); + if (remainingSource) { + this.ctx.chatContainer.addChild( + new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0), + ); + } this.ctx.ui.requestRender(); } catch (error: unknown) { this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); @@ -948,10 +988,10 @@ export class SelectorController { await this.#refreshOAuthProviderAuthState(); const oauthProviders = getOAuthProviders(); const loggedInProviders = oauthProviders.filter(provider => - this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id), + this.ctx.session.modelRegistry.authStorage.has(provider.id), ); if (loggedInProviders.length === 0) { - this.ctx.showStatus("No OAuth providers logged in. Use /login first."); + this.ctx.showStatus("No stored provider credentials to log out. Remove env or config auth at its source."); return; } } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e3792dc26..39238f6b7 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -32,12 +32,26 @@ import { TUI, visibleWidth, } from "@oh-my-pi/pi-tui"; -import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils"; +import { + APP_NAME, + adjustHsv, + formatNumber, + getProjectDir, + hsvToRgb, + isEnoent, + logger, + postmortem, + prompt, + setProjectDir, +} from "@oh-my-pi/pi-utils"; import chalk from "chalk"; +import { reset as resetCapabilities } from "../capability"; import { KeybindingsManager } from "../config/keybindings"; import { MODEL_ROLES, type ModelRole } from "../config/model-registry"; import { isSettingsInitialized, Settings, settings } from "../config/settings"; +import { clearClaudePluginRootsCache } from "../discovery/helpers"; import type { + ContextUsage, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, @@ -123,6 +137,7 @@ import type { CompactionQueuedMessage, InteractiveModeContext, InteractiveModeInitOptions, + InteractiveSelectorDialogOptions, SubmittedUserInput, TodoItem, TodoPhase, @@ -198,6 +213,8 @@ function formatHudNoteMarker(count: number): string { type GoalSubcommand = "set" | "show" | "pause" | "resume" | "drop" | "budget"; const GOAL_SUBCOMMANDS = new Set(["set", "show", "pause", "resume", "drop", "budget"]); +const PLAN_KEEP_CONTEXT_OPTION_INDEX = 2; +const PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT = 95; function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; rest: string } { const trimmed = args.trim(); @@ -211,6 +228,10 @@ function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; r return { sub: undefined, rest: trimmed }; } +function formatContextTokenCount(value: number): string { + return formatNumber(Math.max(0, Math.round(value))).toLowerCase(); +} + /** Options for creating an InteractiveMode instance (for future API use) */ export interface InteractiveModeOptions { /** Providers that were migrated during startup */ @@ -260,6 +281,7 @@ export class InteractiveMode implements InteractiveModeContext { loopPrompt: string | undefined = undefined; loopLimit: LoopLimitRuntime | undefined = undefined; #loopAutoSubmitTimer: NodeJS.Timeout | undefined; + #todoAutoClearTimer: NodeJS.Timeout | undefined; todoPhases: TodoPhase[] = []; hideThinkingBlock = false; pendingImages: ImageContent[] = []; @@ -392,6 +414,7 @@ export class InteractiveMode implements InteractiveModeContext { try { this.historyStorage = HistoryStorage.open(); this.editor.setHistoryStorage(this.historyStorage); + this.historyStorage.setSessionResolver(() => this.sessionManager.getSessionId()); } catch (error) { logger.warn("History storage unavailable", { error: String(error) }); } @@ -555,6 +578,7 @@ export class InteractiveMode implements InteractiveModeContext { // subagent is doing the work for a still-pending todo) updates as // subagents start, finish, or fail. this.#reconcileTodosWithSubagents(); + this.#syncTodoAutoClearTimer(); this.#renderTodoList(); this.ui.requestRender(); }); @@ -641,6 +665,31 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setSlashCommands(fileCommands); } + /** + * Re-point the process and every cwd-derived cache at `newCwd` after the + * active session's working directory changed (`/move` relocation or resuming + * a session from another project). The SessionManager's cwd MUST already + * reflect `newCwd` before this is called. + */ + async applyCwdChange(newCwd: string): Promise { + setProjectDir(newCwd); + // Re-scope project settings (`.claude/settings.yml` etc.) to the new + // directory in place so the active session and every settings reader pick + // up the destination project's configuration. + if (isSettingsInitialized()) { + await settings.reloadForCwd(newCwd); + } + // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so + // the next prompt sees everything scoped to the new project directory. + clearClaudePluginRootsCache(); + resetCapabilities(); + await this.refreshSlashCommandState(newCwd); + await this.session.refreshSshTool({ activateIfAvailable: true }); + setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd()); + this.statusLine.invalidate(); + this.updateEditorTopBorder(); + } + async getUserInput(): Promise { if (this.session.getGoalModeState()?.mode === "exiting") { await this.#exitGoalMode({ reason: "completed", silent: true }); @@ -1043,6 +1092,47 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setTodoPhases(next); } + #cancelTodoAutoClearTimer(): void { + if (!this.#todoAutoClearTimer) return; + clearTimeout(this.#todoAutoClearTimer); + this.#todoAutoClearTimer = undefined; + } + + #isClosedTodo(task: TodoItem): boolean { + return task.status === "completed" || task.status === "abandoned"; + } + + #hasClosedTodos(phases: TodoPhase[]): boolean { + return phases.some(phase => phase.tasks.some(task => this.#isClosedTodo(task))); + } + + #removeClosedTodos(phases: TodoPhase[]): TodoPhase[] { + const next: TodoPhase[] = []; + for (const phase of phases) { + const tasks = phase.tasks.filter(task => !this.#isClosedTodo(task)); + if (tasks.length > 0) next.push({ name: phase.name, tasks }); + } + return next; + } + + #syncTodoAutoClearTimer(): void { + this.#cancelTodoAutoClearTimer(); + const delaySeconds = this.settings.get("tasks.todoClearDelay"); + if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || !this.#hasClosedTodos(this.todoPhases)) return; + if (delaySeconds === 0) { + this.todoPhases = this.#removeClosedTodos(this.todoPhases); + return; + } + + this.#todoAutoClearTimer = setTimeout(() => { + this.#todoAutoClearTimer = undefined; + this.todoPhases = this.#removeClosedTodos(this.todoPhases); + this.#renderTodoList(); + this.ui.requestRender(); + }, delaySeconds * 1000); + this.#todoAutoClearTimer.unref?.(); + } + #getActivePhase(phases: TodoPhase[]): TodoPhase | undefined { const nonEmpty = phases.filter(phase => phase.tasks.length > 0); const active = nonEmpty.find(phase => @@ -1098,6 +1188,7 @@ export class InteractiveMode implements InteractiveModeContext { async #loadTodoList(): Promise { this.todoPhases = this.session.getTodoPhases(); + this.#syncTodoAutoClearTimer(); this.#renderTodoList(); } @@ -1569,6 +1660,28 @@ export class InteractiveMode implements InteractiveModeContext { return `up/down navigate enter select ${externalEditorKey.toLowerCase()} open in editor esc cancel`; } + #getPlanApprovalContextUsage(): ContextUsage | undefined { + const executionModel = this.#planModePreviousModelState?.model ?? this.session.model; + const contextWindow = executionModel?.contextWindow; + if (typeof contextWindow === "number") { + return this.session.getContextUsage({ contextWindow }); + } + return this.session.getContextUsage(); + } + + #formatKeepContextLabel(contextUsage: ContextUsage | undefined): string { + if (contextUsage?.tokens == null) { + return "Approve and keep context"; + } + const tokens = formatContextTokenCount(contextUsage.tokens); + const contextWindow = formatContextTokenCount(contextUsage.contextWindow); + return `Approve and keep context (~${tokens} / ${contextWindow})`; + } + + #isKeepContextDisabled(contextUsage: ContextUsage | undefined): boolean { + return contextUsage?.percent != null && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT; + } + async #openPlanInExternalEditor(planFilePath: string): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) { @@ -2035,11 +2148,9 @@ export class InteractiveMode implements InteractiveModeContext { } this.#renderPlanPreview(planContent, { append: true }); - const contextUsage = this.session.getContextUsage(); - const keepContextLabel = - contextUsage?.percent != null - ? `Approve and keep context (${contextUsage.percent.toFixed(1)}%)` - : "Approve and keep context"; + const contextUsage = this.#getPlanApprovalContextUsage(); + const keepContextLabel = this.#formatKeepContextLabel(contextUsage); + const keepContextDisabled = this.#isKeepContextDisabled(contextUsage); // Model-tier slider: let the operator pick which configured role model // (smol/default/slow/…) executes the approved plan. The slider always starts @@ -2074,6 +2185,7 @@ export class InteractiveMode implements InteractiveModeContext { { helpText, onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath), + disabledIndices: keepContextDisabled ? [PLAN_KEEP_CONTEXT_OPTION_INDEX] : undefined, }, { slider }, ); @@ -2176,6 +2288,7 @@ export class InteractiveMode implements InteractiveModeContext { this.loadingAnimation = undefined; } this.#cleanupMicAnimation(); + this.#cancelTodoAutoClearTimer(); this.#cancelGoalContinuation(); if (this.#sttController) { this.#sttController.dispose(); @@ -2231,14 +2344,10 @@ export class InteractiveMode implements InteractiveModeContext { // Emit shutdown event to hooks await this.session.dispose(); - if (this.isInitialized) { - this.ui.requestRender(true); - } - - // Wait for any pending renders to complete - // requestRender() uses process.nextTick(), so we wait one tick - await new Promise(resolve => process.nextTick(resolve)); - + // Do not force a final render during teardown: disposed session/UI state can + // collapse to an empty frame, clearing the viewport and leaving the parent + // shell prompt at row 0. Stop from the last committed frame so the terminal + // hands Bash the cursor immediately after visible OMP content. // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000); @@ -2858,6 +2967,7 @@ export class InteractiveMode implements InteractiveModeContext { }, ]; } + this.#syncTodoAutoClearTimer(); this.#renderTodoList(); this.ui.requestRender(); } @@ -2898,7 +3008,7 @@ export class InteractiveMode implements InteractiveModeContext { showHookSelector( title: string, options: ExtensionUISelectItem[], - dialogOptions?: ExtensionUIDialogOptions, + dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { return this.#extensionUiController.showHookSelector(title, options, dialogOptions, extra); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 9a6708eec..4f734d53c 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -25,7 +25,7 @@ import type { CustomEditor } from "./components/custom-editor"; import type { EvalExecutionComponent } from "./components/eval-execution"; import type { HookEditorComponent } from "./components/hook-editor"; import type { HookInputComponent } from "./components/hook-input"; -import type { HookSelectorComponent } from "./components/hook-selector"; +import type { HookSelectorComponent, HookSelectorOptions } from "./components/hook-selector"; import type { StatusLineComponent } from "./components/status-line"; import type { ToolExecutionHandle } from "./components/tool-execution"; import type { LoopLimitRuntime } from "./loop-limit"; @@ -64,6 +64,8 @@ export interface InteractiveModeInitOptions { suppressWelcomeIntro?: boolean; } +export type InteractiveSelectorDialogOptions = ExtensionUIDialogOptions & Pick; + export interface InteractiveModeContext { // UI access ui: TUI; @@ -242,6 +244,7 @@ export interface InteractiveModeContext { ): Promise; openInBrowser(urlOrPath: string): void; refreshSlashCommandState(cwd?: string): Promise; + applyCwdChange(newCwd: string): Promise; // Selector handling showSettingsSelector(): void; @@ -299,7 +302,7 @@ export interface InteractiveModeContext { showHookSelector( title: string, options: ExtensionUISelectItem[], - dialogOptions?: ExtensionUIDialogOptions, + dialogOptions?: InteractiveSelectorDialogOptions, ): Promise; hideHookSelector(): void; showHookInput(title: string, placeholder?: string): Promise; diff --git a/packages/coding-agent/src/prompts/system/orchestrate-notice.md b/packages/coding-agent/src/prompts/system/orchestrate-notice.md index 2f5e56fad..84bd5d732 100644 --- a/packages/coding-agent/src/prompts/system/orchestrate-notice.md +++ b/packages/coding-agent/src/prompts/system/orchestrate-notice.md @@ -2,19 +2,20 @@ The user's message above is an **orchestration request**. Execute it as the orchestrator under the contract below. This contract overrides any default tendency to yield early, narrate, or do the work yourself. -You decompose, dispatch, verify, and iterate. You do **not** edit code. Every file mutation goes through a `task` subagent. Your tool budget is: reading for planning, `task` for dispatch, verification (`bun check`, `bun test`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking. +You decompose, dispatch, verify, and iterate. Substantial and parallelizable work goes through `task` subagents — that is the whole point of orchestrating. But you are not forbidden from touching the tree: a trivial, self-contained edit is yours to make directly when spawning a subagent for it would cost more than the edit itself. Your tool budget is: reading for planning, `task` for dispatch, `edit`/`write` for trivial inline fixes only, verification (`bun check`, `bun test`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking. 1. **Do not yield until everything is closed.** A phase finishing is *not* a yield point — launch the next phase in the same turn. Stop only when every requested item is verifiably done, or you hit a concrete [blocked] state that genuinely requires the user. 2. **Enumerate the full surface before dispatching.** If the request references audits, plans, checklists, phase lists, or file lists, expand them into a flat set of items in `todo_write`. "Most of them" or "the important ones" is failure. Re-read the source documents — do not work from memory. -3. **Parallelize maximally.** Every set of edits with disjoint file scope MUST ship as one `task` batch. Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do. +3. **Parallelize maximally; never launch a one-off task.** Every set of edits with disjoint file scope MUST ship as one `task` batch — fan the work as wide as it decomposes. A single-task batch for divisible work is a failure: split it. If you are about to dispatch exactly one subagent, stop — either there is more to run alongside it (find it and batch them) or the change is small enough to make inline yourself (do it). Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do. 4. **Each `task` assignment is self-contained.** Subagents have no shared context. Spell out: target files (≤3–5 explicit paths, no globs), the change with APIs and patterns, edge cases, and observable acceptance criteria. Do not assume they read the same plan you did. 5. **Verify after every phase before launching the next.** Run the appropriate gate: `bun check` for types, package-scoped `bun test` for behavior, `lsp diagnostics` for changed files. If a phase introduced breakage, dispatch fix-up subagents *before* moving on. Never declare a phase done on a red tree. 6. **Commit policy.** If the request asks for commits or the repo workflow expects them, commit after each green phase with a focused message. Never commit a red tree. Never commit work the user did not ask to commit. 7. **Respawn, do not absorb.** If a subagent returns incomplete or wrong work, spawn a corrective subagent with the specific gap — do not silently fix it yourself. 8. **No scope creep, no scope shrink.** Do not add work the user did not ask for. Do not relabel unfinished items as "follow-up", "v1", or "MVP" to imply completion. 9. **Subagents do not verify, lint, or format.** Every `task` assignment MUST instruct the subagent to skip all gates and formatters. Their job is the edit only. You — the orchestrator — run verification and formatting **once** at the end of the phase across the union of changed files. Avoids redundant runs and racing formatter passes. +10. **Right-size the offload — do not micro-task.** Subagents are for substantial or parallelizable chunks, not every keystroke. A trivial, self-contained mechanical edit — deleting a redundant glob, fixing one line in a config, renaming a single symbol in one file — costs less to *do* than to describe in a Goal/Constraints assignment. Make those yourself with `edit`/`write` and move on; reserve `task`/`quick_task` for work large enough to justify the dispatch overhead. Wrapping a one-line change in a full subagent with scaffolding is pure waste. @@ -28,7 +29,8 @@ You decompose, dispatch, verify, and iterate. You do **not** edit code. Every fi -- Editing files yourself "because it's faster". +- Doing substantial or parallelizable work yourself instead of fanning it out to subagents. +- Wrapping a single trivial edit (e.g. removing one redundant config line) in a `task`/`quick_task` with full Goal/Constraints scaffolding — just make the edit inline. - Yielding after phase 1 with "ready to continue?". - Dispatching one subagent at a time when five could run in parallel. - Skipping `bun check` between phases because "the change looked safe". diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index 1c620fb85..624a16446 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -14,8 +14,8 @@ Worth it when the task benefits from decomposition + parallel coverage, or from State persists across cells, so scout in one cell and fan out in the next. Every cell has: - `agent(prompt, *, agent_type="task", model=None, context=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `context` is shared background; `label` names the artifact. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. -- `parallel(thunks, *, concurrency=4)` — run zero-arg callables concurrently through a bounded pool (default 4, max 16), preserving input order; returns once all finish. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one. -- `pipeline(items, *stages, concurrency=4)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. +- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool runs as wide as a `task` tool batch (the `task.maxConcurrency` setting; don't hand-tune it — fan out as wide as the work divides). A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one. +- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`. - `llm(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out. - `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it. - `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget. diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 1c5353eea..407136c9f 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -1,7 +1,7 @@ Run code in a persistent kernel using a list of cells. -Each call submits one or more cells. Cells run in array order. State persists within each language across cells, tool calls, and subagents spawned with `task`; variables a parent or subagent declares are visible to the other on the same shared executor. +Each call submits one or more cells. Cells run in array order. State persists within each language across cells, tool calls, and subagents spawned with `task`; variables a parent or subagent declares are visible to the other on the same shared executor. Lean on this: stage helpers, loaded datasets, or live clients once, then fan out `task` subagents that call them directly — no re-importing, re-fetching, or serializing across the boundary. Cell fields: @@ -48,10 +48,10 @@ llm(prompt, model?="default", system?=None, schema?=None) → str | dict Oneshot, stateless LLM call (no history, no tools). `model` picks a tier: "smol" (fast), "default" (this session's model), "slow" (most capable). Pass `system` for a system prompt. Pass a JSON-Schema `schema` to force structured output and get the parsed object back; otherwise returns the completion text. agent(prompt, agent_type?="task", model?=None, context?=None, label?=None, schema?=None) → str | dict Run a subagent and return its final output. Defaults to the bundled "task" agent; pass `agent_type`/`agentType` for another discovered agent. Pass a JSON-Schema `schema` to force structured output and get the parsed object back. -parallel(thunks, concurrency?=4) → list - Run thunks (callables) through a bounded pool (default 4, max 16), preserving input order. Barrier: returns once all finish; a thunk that throws propagates. -pipeline(items, ...stages, concurrency?=4) → list - Map each item through stages left-to-right; a barrier runs between stages (every item clears stage N before stage N+1). Each stage is a one-arg callable: stage 1 gets the original item, later stages get the previous result. +parallel(thunks) → list + Run thunks (callables) through a bounded pool, preserving input order. The pool is as wide as a `task` tool batch (tracks the `task.maxConcurrency` setting), so fan out as wide as the work divides — don't pre-shrink it. Barrier: returns once all finish; a thunk that throws propagates. +pipeline(items, ...stages) → list + Map each item through stages left-to-right; a barrier runs between stages (every item clears stage N before stage N+1). Each stage is a one-arg callable: stage 1 gets the original item, later stages get the previous result. Same pool width as parallel(). log(message) → None Emit a progress line above the status tree. phase(title) → None diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index b6b0a0f6e..d3b738e91 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -2,7 +2,7 @@ Finds files and directories using fast pattern matching that works with any code - `paths` is required and accepts an array of globs, files, or directories -- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected) +- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`). - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored - `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit diff --git a/packages/coding-agent/src/prompts/tools/irc.md b/packages/coding-agent/src/prompts/tools/irc.md index e29d0b5a5..8dbeda10c 100644 --- a/packages/coding-agent/src/prompts/tools/irc.md +++ b/packages/coding-agent/src/prompts/tools/irc.md @@ -1,7 +1,7 @@ Sends short text messages to other live agents in this process and receives their prose replies. -- The main agent is addressable as `0-Main`. Subagents reuse their task id (e.g. `0-AuthLoader`). +- The main agent is addressable as `Main`. Subagents reuse their task id (e.g. `AuthLoader`, or `AuthLoader-2` when the name repeats). - `op: "list"` returns the current set of visible peers. Use it before sending if you are not sure who is live. - `op: "send"` delivers `message` to `to`. `to` may be a specific id or `"all"` to broadcast. - The recipient generates the reply via an ephemeral side-channel turn that uses their current model, system prompt, and history — it does **not** wait for the recipient's main loop to be free, so it is safe to IRC an agent that is currently inside a long-running tool call. @@ -10,7 +10,7 @@ Sends short text messages to other live agents in this process and receives thei You SHOULD reach for `irc` proactively when continuing alone is wasteful or wrong. When in doubt, prefer messaging. -- **Unexpected state.** You hit something the original task did not describe — a missing file, a config that contradicts the assignment, an API behaving differently than you were told, a tool failing in a way that suggests the spec is wrong. DM `0-Main` (or the spawning agent) for guidance instead of guessing. +- **Unexpected state.** You hit something the original task did not describe — a missing file, a config that contradicts the assignment, an API behaving differently than you were told, a tool failing in a way that suggests the spec is wrong. DM `Main` (or the spawning agent) for guidance instead of guessing. - **Blocked by another agent.** A peer holds the file/branch/resource you need, has already started the change you are about to make, or owns a decision you depend on. DM that peer (or broadcast to discover who) before duplicating or stepping on work. - **Decision points outside your scope.** A genuine fork in the road that the assignment did not pre-decide (e.g. which of two viable APIs to use, whether to refactor adjacent code). Ask the requester rather than picking unilaterally. - **Coordination opportunities.** You realize a peer's in-flight work would benefit from yours, or vice-versa. @@ -25,7 +25,7 @@ These rules apply to both sending and replying. - **Use IRC, not terminal tools, to learn about peers.** Do not `grep` artifacts, read other sessions' JSONL files, or shell-poke around to figure out what another agent is doing. DM them — they have the live answer and you do not. - **One round-trip is enough.** Replies arrive synchronously when the recipient is reachable. Do not follow up with "did you get my message?" — they did. If `delivered` is empty or the result was `failed`, the peer is unavailable; move on or report the blocker, do not retry in a loop. - **Stay terse.** A DM is a chat message, not a memo. One question per send when you can. Share file paths and artifacts via `local://` / `memory://` / `artifact://` URLs instead of pasting blobs. -- **Address peers by id.** Use the exact id from `op: "list"` (e.g. `0-AuthLoader`, `0-Main`). Do not invent friendly names. +- **Address peers by id.** Use the exact id from `op: "list"` (e.g. `AuthLoader`, `Main`). Do not invent friendly names. - **Do not IRC for things a tool would answer.** If a `read`, `grep`, or build command would resolve the question, do that first. - **When you receive an IRC message, answer it before continuing.** The recipient injects the question + your auto-reply into your history; address it directly, do not repeat it back to the user. @@ -39,11 +39,11 @@ These rules apply to both sending and replying. # List peers `{"op": "list"}` # Direct message to the main agent (waits for prose reply) -`{"op": "send", "to": "0-Main", "message": "Should I prefer JWT or session cookies for the auth flow?"}` +`{"op": "send", "to": "Main", "message": "Should I prefer JWT or session cookies for the auth flow?"}` # Unexpected state — ask the originator -`{"op": "send", "to": "0-Main", "message": "Assignment says edit src/auth/jwt.ts but the file does not exist. Is the new path src/server/auth/jwt.ts?"}` +`{"op": "send", "to": "Main", "message": "Assignment says edit src/auth/jwt.ts but the file does not exist. Is the new path src/server/auth/jwt.ts?"}` # Blocked by a peer — ask them directly -`{"op": "send", "to": "0-AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path; OK to proceed or should I wait?"}` +`{"op": "send", "to": "AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path; OK to proceed or should I wait?"}` # Broadcast to discover who owns something (no replies, just informs them) `{"op": "send", "to": "all", "message": "About to refactor src/server/middleware/*. Anyone already in there?", "awaitReply": false}` diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index b011fbea1..52cb4b82b 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -3,7 +3,7 @@ Searches files using powerful regex matching. - Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(? diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index c8b422b48..252b79381 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -2,7 +2,7 @@ Launches subagents to parallelize workflows. {{#if asyncEnabled}} - Results are delivered automatically when complete. -- The tool result lists the assigned task ids (e.g. `0-AuthLoader`) — those are the live agent ids. +- The tool result lists the assigned task ids (e.g. `AuthLoader`) — those are the live agent ids. {{#if ircEnabled}} - Coordinate with running tasks via `irc` using those ids. `job cancel` terminates a task and **cannot carry a message** — only use it for stalled/abandoned work. - If genuinely blocked on completion, wait with `job poll`; otherwise keep working. diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index ae4abe19c..271b9d90b 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -8,7 +8,7 @@ import type { AgentSession } from "../session/agent-session"; -export const MAIN_AGENT_ID = "0-Main"; +export const MAIN_AGENT_ID = "Main"; export type AgentStatus = "running" | "idle" | "completed" | "aborted"; export type AgentKind = "main" | "sub"; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 5b2b7f85d..4bd16d8a7 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -102,7 +102,7 @@ import { AgentSession } from "./session/agent-session"; import { resolveAuthBrokerConfig } from "./session/auth-broker-config"; import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage"; import { type CustomMessage, convertToLlm } from "./session/messages"; -import { SessionManager } from "./session/session-manager"; +import { getRestorableSessionModels, SessionManager } from "./session/session-manager"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; import { @@ -323,13 +323,13 @@ export interface CreateAgentSessionOptions { parentHindsightSessionState?: HindsightSessionState; /** Parent Mnemopi state to alias for subagent memory tools. */ parentMnemopiSessionState?: MnemopiSessionState; - /** Pre-allocated agent identity for IRC routing. Default: "0-Main" for top-level, parentTaskPrefix-derived for sub. */ + /** Pre-allocated agent identity for IRC routing. Default: "Main" for top-level, parentTaskPrefix-derived for sub. */ agentId?: string; /** Display name for the agent in IRC. Default: "main" or "sub". */ agentDisplayName?: string; /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */ agentRegistry?: AgentRegistry; - /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */ + /** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */ parentTaskPrefix?: string; /** Inherited eval executor session id for subagents sharing parent eval state. */ parentEvalSessionId?: string; @@ -1008,20 +1008,37 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ); let model = options.model; let modelFallbackMessage: string | undefined; - // If session has data, try to restore model from it. - // Skip restore when an explicit model was requested. - const defaultModelStr = existingSession.models.default; - if (!hasExplicitModel && !model && hasExistingSession && defaultModelStr) { + // Identify session model strings to restore in fallback order. We do an + // initial pass here so model-dependent setup (thinking-level resolution, + // host preconnect) can use the restored model; extension-registered + // providers aren't visible yet, so we retry the preferred candidates once + // extensions register below. + const sessionModelStrings = + !hasExplicitModel && hasExistingSession + ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole()) + : []; + let restoredSessionModelIndex = -1; + if (!hasExplicitModel && !model && sessionModelStrings.length > 0) { await logger.time("restoreSessionModel", async () => { - const parsedModel = parseModelString(defaultModelStr); - if (parsedModel) { + let failedSessionModel: string | undefined; + for (let i = 0; i < sessionModelStrings.length; i++) { + const sessionModelStr = sessionModelStrings[i]; + const parsedModel = parseModelString(sessionModelStr); + if (!parsedModel) { + failedSessionModel ??= sessionModelStr; + continue; + } + const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); if (restoredModel && (await hasModelApiKey(restoredModel))) { model = restoredModel; + restoredSessionModelIndex = i; + break; } + failedSessionModel ??= sessionModelStr; } - if (!model) { - modelFallbackMessage = `Could not restore model ${defaultModelStr}`; + if (failedSessionModel) { + modelFallbackMessage = `Could not restore model ${failedSessionModel}`; } }); } @@ -1039,26 +1056,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const taskDepth = options.taskDepth ?? 0; - let thinkingLevel = options.thinkingLevel; - - // If session has data and includes a thinking entry, restore it - if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) { - thinkingLevel = parseThinkingLevel(existingSession.thinkingLevel); - } - - if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { - thinkingLevel = defaultRoleSpec.thinkingLevel; - } - - // Prefer the selected model's configured defaultLevel, otherwise fall back - // to the global settings default. - if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) { - thinkingLevel = model.thinking.defaultLevel; - } - if (thinkingLevel === undefined) { - thinkingLevel = settings.get("defaultThinkingLevel"); - } - const autoThinking = thinkingLevel === AUTO_THINKING; + // Resolves the session/agent thinking level using the same precedence we + // apply at startup: explicit option → persisted session entry → default + // role's explicit selector → selected model's defaultLevel → global + // settings default. Run again after extension role reclaim so the final + // model's own defaults aren't masked by an earlier fallback model's. + const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => { + let level = options.thinkingLevel; + if (level === undefined && hasExistingSession && hasThinkingEntry) { + level = parseThinkingLevel(existingSession.thinkingLevel); + } + if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { + level = defaultRoleSpec.thinkingLevel; + } + if (level === undefined && selectedModel?.thinking?.defaultLevel !== undefined) { + level = selectedModel.thinking.defaultLevel; + } + if (level === undefined) { + level = settings.get("defaultThinkingLevel"); + } + return level; + }; + let thinkingLevel = pickInitialThinkingLevel(model); + let autoThinking = thinkingLevel === AUTO_THINKING; // Concrete level the agent/session start with. With `auto` this is the // provisional level shown until the first per-turn classification resolves; // `auto` itself stays a session-only concept handled by AgentSession. @@ -1444,6 +1464,40 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} extensionsResult.runtime.pendingProviderRegistrations = []; } + // Retry session-model candidates now that extension providers are + // registered. The initial restore runs before extensions load, so a role + // model supplied by an extension would have either fallen back to the + // saved default (`restoredSessionModelIndex > 0`) or failed entirely + // (`restoredSessionModelIndex === -1`, with the settings default or + // downstream fallback filling `model`). Reclaim it here so resume + // honors the last active role in either case. + const sessionRetryLimit = restoredSessionModelIndex >= 0 ? restoredSessionModelIndex : sessionModelStrings.length; + if (!hasExplicitModel && sessionRetryLimit > 0) { + for (let i = 0; i < sessionRetryLimit; i++) { + const sessionModelStr = sessionModelStrings[i]; + const parsedModel = parseModelString(sessionModelStr); + if (!parsedModel) continue; + const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); + if (restoredModel && (await hasModelApiKey(restoredModel))) { + model = restoredModel; + modelFallbackMessage = undefined; + restoredSessionModelIndex = i; + // Recompute thinking-level from scratch against the reclaimed + // model: any value derived from the earlier fallback model's + // `thinking.defaultLevel` must not become sticky. + thinkingLevel = pickInitialThinkingLevel(restoredModel); + autoThinking = thinkingLevel === AUTO_THINKING; + effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel; + effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () => + autoThinking + ? resolveProvisionalAutoLevel(restoredModel) + : resolveThinkingLevelForModel(restoredModel, effectiveThinkingLevel), + ); + preconnectModelHost(restoredModel.baseUrl); + break; + } + } + } // Resolve deferred --model pattern now that extension models are registered. if (!model && options.modelPattern) { const availableModels = modelRegistry.getAll(); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ea38e483d..738985f4f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -62,6 +62,7 @@ import type { Message, MessageAttribution, Model, + ProviderResponseMetadata, ProviderSessionState, ServiceTier, SimpleStreamOptions, @@ -87,6 +88,7 @@ import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { extractRetryHint, getAgentDbPath, + getInstallId, isEnoent, isUnexpectedSocketCloseMessage, logger, @@ -227,7 +229,7 @@ import type { SessionContext, SessionManager, } from "./session-manager"; -import { getLatestCompactionEntry } from "./session-manager"; +import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager"; import type { ShakeMode, ShakeResult } from "./shake-types"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { YieldQueue } from "./yield-queue"; @@ -358,7 +360,7 @@ export interface AgentSessionConfig { * **MUST NOT** dispose it on their own teardown. */ ownedAsyncJobManager?: AsyncJobManager; - /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */ + /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */ agentId?: string; /** Shared agent registry (for forwarding IRC observations to the main session UI). */ agentRegistry?: AgentRegistry; @@ -581,15 +583,14 @@ function buildSessionMetadata( const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId); if (typeof accountUuid === "string" && accountUuid.length > 0) { userId.account_uuid = accountUuid; - // Derive device_id from account_uuid so the payload matches the real CC - // getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a - // namespaced account UUID produces a stable 64-hex value that is - // indistinguishable from a randomly generated device ID on the wire, is - // deterministic per account (survives reinstalls), and is auditable: it - // is derived solely from the OAuth UUID the user already consented to - // share with Anthropic. Omitted when no OAuth credential is available - // (API-key callers) to avoid sending a hash of an empty string. - userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex"); + // Claude Code's `device_id` is a stable 64-hex install identifier. Use + // omp's persistent install id as the root instead of deriving it from + // `account_uuid`: logging into a different Claude account on the same + // install should not make the device look new. + userId.device_id = crypto + .createHash("sha256") + .update(`omp-claude-device-id-v1:${getInstallId()}`) + .digest("hex"); } } return { user_id: JSON.stringify(userId) }; @@ -1104,10 +1105,12 @@ export class AgentSession { this.#onResponse = configuredOnResponse ? async (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); + this.#ingestProviderUsageHeaders(response, model); await configuredOnResponse(response, model); } : (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); + this.#ingestProviderUsageHeaders(response, model); }; const configuredOnSseEvent = config.onSseEvent; this.#onSseEvent = configuredOnSseEvent @@ -2827,13 +2830,14 @@ export class AgentSession { /** * Set agent.sessionId from the session manager and install a dynamic - * metadata resolver so every API request carries `metadata.user_id` shaped - * like real Claude Code's `getAPIMetadata` output: `{ session_id, - * account_uuid }` (the latter only when an Anthropic OAuth credential with - * a known account UUID is loaded). Resolving live keeps the value in sync - * with auth-state changes (login/logout, token refresh that surfaces a new - * account uuid) without needing to re-call `#syncAgentSessionId()` on every - * such event. + * metadata resolver so every Anthropic API request carries + * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output: + * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only + * when an Anthropic OAuth credential with a known account UUID is loaded; + * `device_id` is derived from the persistent omp install id. Resolving live + * keeps the value in sync with auth-state changes (login/logout, token + * refresh that surfaces a new account uuid) without needing to re-call + * `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId(); @@ -8723,28 +8727,34 @@ export class AgentSession { } // Restore model if saved - const defaultModelStr = sessionContext.models.default; - if (defaultModelStr) { - const slashIdx = defaultModelStr.indexOf("/"); - if (slashIdx > 0) { - const provider = defaultModelStr.slice(0, slashIdx); - const modelId = defaultModelStr.slice(slashIdx + 1); - const availableModels = this.#modelRegistry.getAvailable(); - const match = availableModels.find(m => m.provider === provider && m.id === modelId); - if (match) { - const currentModel = this.model; - const shouldResetProviderState = - switchingToDifferentSession || - (currentModel !== undefined && - (currentModel.provider !== match.provider || - currentModel.id !== match.id || - currentModel.api !== match.api)); - if (shouldResetProviderState) { - this.#setModelWithProviderSessionReset(match); - } else { - this.agent.setModel(match); - this.#syncToolCallBatchCap(match); - } + const targetModelStrings = getRestorableSessionModels( + sessionContext.models, + this.sessionManager.getLastModelChangeRole(), + ); + if (targetModelStrings.length > 0) { + const availableModels = this.#modelRegistry.getAvailable(); + let match: Model | undefined; + for (const targetModelStr of targetModelStrings) { + const slashIdx = targetModelStr.indexOf("/"); + if (slashIdx <= 0) continue; + const provider = targetModelStr.slice(0, slashIdx); + const modelId = targetModelStr.slice(slashIdx + 1); + match = availableModels.find(m => m.provider === provider && m.id === modelId); + if (match) break; + } + if (match) { + const currentModel = this.model; + const shouldResetProviderState = + switchingToDifferentSession || + (currentModel !== undefined && + (currentModel.provider !== match.provider || + currentModel.id !== match.id || + currentModel.api !== match.api)); + if (shouldResetProviderState) { + this.#setModelWithProviderSessionReset(match); + } else { + this.agent.setModel(match); + this.#syncToolCallBatchCap(match); } } } @@ -9202,12 +9212,10 @@ export class AgentSession { * Uses the last assistant message's usage data when available, * otherwise estimates tokens for all messages. */ - getContextUsage(): ContextUsage | undefined { + getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined { const model = this.model; - if (!model) return undefined; - - const contextWindow = model.contextWindow ?? 0; - if (contextWindow <= 0) return undefined; + const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0; + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined; // After compaction, the last assistant usage reflects pre-compaction context size. // We can only trust usage from an assistant that responded after the latest compaction. @@ -9248,6 +9256,14 @@ export class AgentSession { }; } + #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void { + if (model?.provider !== "anthropic") return; + this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, { + sessionId: this.agent.sessionId, + baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"), + }); + } + async fetchUsageReports(signal?: AbortSignal): Promise { const authStorage = this.#modelRegistry.authStorage; if (!authStorage.fetchUsageReports) return null; @@ -9265,7 +9281,7 @@ export class AgentSession { } { const messages = this.messages; - // Find last assistant message with usage + // Find last assistant message with valid usage. let lastUsageIndex: number | null = null; let lastUsage: Usage | undefined; for (let i = messages.length - 1; i >= 0; i--) { diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index 1306cf1e9..1001d788e 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -8,6 +8,8 @@ export interface HistoryEntry { prompt: string; created_at: number; cwd?: string; + /** ID of the session the prompt was submitted from, if known. */ + sessionId?: string; } type HistoryRow = { @@ -15,6 +17,7 @@ type HistoryRow = { prompt: string; created_at: number; cwd: string | null; + session_id: string | null; }; const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; @@ -62,7 +65,8 @@ class AsyncDrain { export class HistoryStorage { #db: Database; static #instance?: HistoryStorage; - #drain = new AsyncDrain>(100); + #drain = new AsyncDrain>(100); + #sessionResolver?: () => string | undefined; // Prepared statements #insertRowStmt: Statement; @@ -91,7 +95,8 @@ CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - cwd TEXT + cwd TEXT, + session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); @@ -106,6 +111,10 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN this.#migrateHistorySchema(); } + if (!this.#historySchemaHasColumn("session_id")) { + this.#db.run("ALTER TABLE history ADD COLUMN session_id TEXT"); + } + if (!hasFts) { try { this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')"); @@ -115,14 +124,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN } this.#recentStmt = this.#db.prepare( - "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?", + "SELECT id, prompt, created_at, cwd, session_id FROM history ORDER BY created_at DESC, id DESC LIMIT ?", ); this.#searchStmt = this.#db.prepare( - "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?", + "SELECT h.id, h.prompt, h.created_at, h.cwd, h.session_id FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?", ); this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1"); - this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); + this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd, session_id) VALUES (?, ?, ?)"); const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined; this.#lastPromptCache = last?.prompt ?? null; @@ -140,20 +149,30 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN HistoryStorage.#instance = undefined; } - #insertBatch(rows: Array>): void { - this.#db.transaction((rows: Array>) => { + #insertBatch(rows: Array>): void { + this.#db.transaction((rows: Array>) => { for (const row of rows) { - this.#insertRowStmt.run(row.prompt, row.cwd ?? null); + this.#insertRowStmt.run(row.prompt, row.cwd ?? null, row.sessionId ?? null); } })(rows); } - add(prompt: string, cwd?: string): Promise { + /** + * Register a resolver that supplies the current session ID for prompts added + * without an explicit `sessionId`. Evaluated synchronously at `add()` time so + * batched writes capture the session active when the prompt was submitted. + */ + setSessionResolver(resolver: () => string | undefined): void { + this.#sessionResolver = resolver; + } + + add(prompt: string, cwd?: string, sessionId?: string): Promise { const trimmed = prompt.trim(); if (!trimmed) return Promise.resolve(); if (this.#lastPromptCache === trimmed) return Promise.resolve(); this.#lastPromptCache = trimmed; - return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined }, rows => { + const session = sessionId ?? this.#sessionResolver?.(); + return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined, sessionId: session || undefined }, rows => { this.#insertBatch(rows); }); } @@ -224,6 +243,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN return merged; } + /** + * IDs of the sessions whose stored prompts match `query`, ordered by match + * relevance (most relevant/recent first) and de-duplicated. Prompts with no + * recorded session are skipped. Used to augment session ranking in the + * resume picker with prompts that the 4KB session-list prefix never sees. + */ + matchingSessionIds(query: string, limit = 500): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const entry of this.search(query, limit)) { + const id = entry.sessionId; + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; + } + #ensureDir(dbPath: string): void { const dir = path.dirname(dbPath); fs.mkdirSync(dir, { recursive: true }); @@ -236,6 +273,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN return row?.sql?.includes("unixepoch(") ?? false; } + #historySchemaHasColumn(column: string): boolean { + const columns = this.#db.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>; + return columns.some(col => col.name === column); + } + #migrateHistorySchema(): void { const migrate = this.#db.transaction(() => { this.#db.run("ALTER TABLE history RENAME TO history_legacy"); @@ -247,7 +289,8 @@ CREATE TABLE history ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - cwd TEXT + cwd TEXT, + session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); INSERT INTO history (id, prompt, created_at, cwd) @@ -294,7 +337,7 @@ END; if (stmt) return stmt; const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND "); stmt = this.#db.prepare( - `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`, + `SELECT id, prompt, created_at, cwd, session_id FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`, ); this.#substringStmts.set(tokenCount, stmt); return stmt; @@ -306,6 +349,7 @@ END; prompt: row.prompt, created_at: row.created_at, cwd: row.cwd ?? undefined, + sessionId: row.session_id ?? undefined, }; } } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6c19193b6..9b08ee159 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -254,6 +254,22 @@ export interface SessionContext { modeData?: Record; } +/** Lists session model strings to try when restoring, in fallback order. */ +export function getRestorableSessionModels( + models: Readonly>, + lastModelChangeRole: string | undefined, +): string[] { + const defaultModel = models.default; + if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") { + return defaultModel ? [defaultModel] : []; + } + + const roleModel = models[lastModelChangeRole]; + if (!roleModel) return defaultModel ? [defaultModel] : []; + if (!defaultModel || roleModel === defaultModel) return [roleModel]; + return [roleModel, defaultModel]; +} + export interface SessionInfo { path: string; id: string; @@ -1808,6 +1824,8 @@ export async function resolveResumableSession( return { session: globalMatch, scope: "global" }; } interface SessionManagerStateSnapshot { + cwd: string; + sessionDir: string; sessionId: string; sessionName: string | undefined; titleSource: "auto" | "user" | undefined; @@ -1880,6 +1898,8 @@ export class SessionManager { captureState(): SessionManagerStateSnapshot { return { + cwd: this.cwd, + sessionDir: this.sessionDir, sessionId: this.#sessionId, sessionName: this.#sessionName, titleSource: this.#titleSource, @@ -1893,6 +1913,8 @@ export class SessionManager { } restoreState(snapshot: SessionManagerStateSnapshot): void { + this.cwd = snapshot.cwd; + this.sessionDir = snapshot.sessionDir; this.#sessionId = snapshot.sessionId; this.#sessionName = snapshot.sessionName; this.#titleSource = snapshot.titleSource; @@ -1938,6 +1960,18 @@ export class SessionManager { this.#sessionName = header?.title; this.#titleSource = header?.titleSource; + // Adopt the loaded session's own working directory. Sessions are stored in + // a directory keyed by their cwd, so resuming a session from another + // project (e.g. global review in the picker) must re-point cwd/sessionDir + // at that project. Same-cwd resumes and in-place reloads are a no-op; old + // sessions with no recorded cwd keep the current cwd. + const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined; + if (headerCwd && headerCwd !== this.cwd) { + this.cwd = headerCwd; + this.sessionDir = path.resolve(this.#sessionFile, ".."); + writeTerminalBreadcrumb(this.cwd, this.#sessionFile); + } + this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries); await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore); diff --git a/packages/coding-agent/src/task/output-manager.ts b/packages/coding-agent/src/task/output-manager.ts index cc64ff402..46d6593c0 100644 --- a/packages/coding-agent/src/task/output-manager.ts +++ b/packages/coding-agent/src/task/output-manager.ts @@ -1,29 +1,28 @@ /** * Session-scoped manager for agent output IDs. * - * Ensures unique output IDs across task tool invocations within a session. - * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi"). - * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask"). + * Keeps every subagent output id unique within a session without polluting the + * common case with bookkeeping. A requested name is used verbatim the first + * time it appears; only a *repeated* name gets a numeric suffix to disambiguate + * it (e.g. "Anna", "Anna-2", "Anna-3"). When a parent prefix is configured, ids + * are nested under it (e.g. "Anna.Bob") so hierarchical outputs stay grouped. * - * This enables reliable agent:// URL resolution and prevents artifact collisions. + * This enables reliable agent:// URL resolution and prevents artifact + * collisions across repeated or nested task invocations. */ import * as fs from "node:fs/promises"; -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** * Manages agent output ID allocation to ensure uniqueness. * - * Each allocated ID gets a numeric prefix based on allocation order. - * If configured with a parent prefix, the numeric prefix is appended after - * the parent (e.g., "0-Parent.0-Child"). - * On resume, scans existing files to find the next available index. + * The first allocation of a given name keeps the name as-is; subsequent + * allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans + * existing output files so previously written outputs are never overwritten. */ export class AgentOutputManager { - #nextId = 0; #initialized = false; + /** Final ids already handed out, relative to this manager's scope. */ + readonly #taken = new Set(); readonly #getArtifactsDir: () => string | null; readonly #parentPrefix: string | undefined; @@ -33,8 +32,8 @@ export class AgentOutputManager { } /** - * Scan existing agent output files to find the next available ID. - * This ensures we don't overwrite outputs when resuming a session. + * Seed the taken-id set from output files already on disk so a resumed + * session never reuses a name that would clobber a prior subagent's output. */ async #ensureInitialized(): Promise { if (this.#initialized) return; @@ -50,31 +49,41 @@ export class AgentOutputManager { return; // Directory doesn't exist yet } - const pattern = this.#parentPrefix - ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`) - : /^(\d+)-.*\.md$/; - - let maxId = -1; + const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; for (const file of files) { - const match = file.match(pattern); - if (match) { - const id = Number.parseInt(match[1], 10); - if (id > maxId) maxId = id; + if (!file.endsWith(".md")) continue; + let rest = file.slice(0, -3); // drop ".md" + if (prefix) { + if (!rest.startsWith(prefix)) continue; + rest = rest.slice(prefix.length); } + // Requested ids never contain "."; a dot marks a nested child, so this + // manager only owns the first segment of whatever remains. + const dot = rest.indexOf("."); + const segment = dot === -1 ? rest : rest.slice(0, dot); + if (segment) this.#taken.add(segment); } - this.#nextId = maxId + 1; + } + + /** Pick the first free name (base, then `base-2`, `base-3`, …) and reserve it. */ + #allocateUnique(id: string): string { + let candidate = id; + for (let n = 2; this.#taken.has(candidate); n++) { + candidate = `${id}-${n}`; + } + this.#taken.add(candidate); + return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate; } /** - * Allocate a unique ID with numeric prefix. + * Allocate a unique ID. * - * @param id Requested ID (e.g., "AuthProvider") - * @returns Unique ID with prefix (e.g., "0-AuthProvider") + * @param id Requested ID (e.g., "Anna") + * @returns Unique ID ("Anna" first, then "Anna-2", "Anna-3", …) */ async allocate(id: string): Promise { await this.#ensureInitialized(); - const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; - return `${prefix}${this.#nextId++}-${id}`; + return this.#allocateUnique(id); } /** @@ -85,23 +94,6 @@ export class AgentOutputManager { */ async allocateBatch(ids: string[]): Promise { await this.#ensureInitialized(); - const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; - return ids.map(id => `${prefix}${this.#nextId++}-${id}`); - } - - /** - * Get the next ID that would be allocated (without allocating). - */ - async peekNextIndex(): Promise { - await this.#ensureInitialized(); - return this.#nextId; - } - - /** - * Reset state (primarily for testing). - */ - reset(): void { - this.#nextId = 0; - this.#initialized = false; + return ids.map(id => this.#allocateUnique(id)); } } diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index a7473d986..5babac27e 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -128,15 +128,10 @@ function formatJsonScalar(value: unknown, _theme: Theme): string { } function formatTaskId(id: string): string { + // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels + // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb. const segments = id.split("."); - if (segments.length < 2) return id; - - const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/)); - if (parsed.some(match => !match)) return id; - - const indices = parsed.map(match => match![1]).join("."); - const labels = parsed.map(match => match![2]).join(">"); - return `${indices} ${labels}`; + return segments.length < 2 ? id : segments.join(">"); } const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool"; diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 308cd6e27..5bd974888 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -815,18 +815,21 @@ export class WorkerCore { { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 }, ); const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined; + const saveFullRes = !!(explicitPath || session.browserScreenshotDir); + const savedBuffer = saveFullRes ? buffer : resized.buffer; + const savedMimeType = saveFullRes ? "image/png" : resized.mimeType; + // Auto-generated names must match the bytes we actually write: full-res is always + // PNG, but the resized buffer is whichever of PNG/JPEG/WebP encoded smallest. + const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png"; const dest = explicitPath ?? (session.browserScreenshotDir ? path.join( session.browserScreenshotDir, - `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`, + `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`, ) - : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`)); + : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`)); await fs.promises.mkdir(path.dirname(dest), { recursive: true }); - const saveFullRes = !!(explicitPath || session.browserScreenshotDir); - const savedBuffer = saveFullRes ? buffer : resized.buffer; - const savedMimeType = saveFullRes ? "image/png" : resized.mimeType; await Bun.write(dest, savedBuffer); const info: ScreenshotResult = { dest, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 5c0678da6..ec938b39a 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -16,6 +16,7 @@ import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { + expandDelimitedPathEntries, formatPathRelativeToCwd, hasGlobPathChars, normalizePathLikeInput, @@ -52,33 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000; const MIN_GLOB_TIMEOUT_MS = 500; const MAX_GLOB_TIMEOUT_MS = 60_000; -/** - * Reject comma-separated path lists packed into a single array element - * (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a - * single comma-joined element get silent no-matches otherwise. - * - * Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and - * must pass through. - */ -export function validateFindPathInputs(paths: readonly string[]): void { - for (const entry of paths) { - let braceDepth = 0; - for (let i = 0; i < entry.length; i++) { - const ch = entry.charCodeAt(i); - if (ch === 0x5c /* \ */ && i + 1 < entry.length) { - i++; - continue; - } - if (ch === 0x7b /* { */) braceDepth++; - else if (ch === 0x7d /* } */) { - if (braceDepth > 0) braceDepth--; - } else if (ch === 0x2c /* , */ && braceDepth === 0) { - throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`); - } - } - } -} - /** * Group find matches by their directory so the model doesn't pay repeated * tokens for shared path prefixes. Preserves the input order: groups appear in @@ -180,8 +154,10 @@ export class FindTool implements AgentTool { return untilAborted(signal, async () => { const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); - validateFindPathInputs(paths); - const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/")); + const rawPatternInputs = this.#customOps + ? paths + : await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern }); + const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/")); const internalRouter = InternalUrlRouter.instance(); const normalizedPatterns: string[] = []; for (const rawPattern of rawPatterns) { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index a8bb4ec8f..a8790946a 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -160,7 +160,7 @@ export interface ToolSession { getHindsightSessionState?: () => HindsightSessionState | undefined; /** Get Mnemopi runtime state for this agent session. */ getMnemopiSessionState?: () => MnemopiSessionState | undefined; - /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */ + /** Agent identity used for IRC routing. Returns the registry id (e.g. "Main", "AuthLoader"). */ getAgentId?: () => string | null; /** Look up a registered tool by name (used by the eval js backend's tool bridge). */ getToolByName?: (name: string) => AgentTool | undefined; diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 749ced72b..645f1390c 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -379,6 +379,145 @@ export function hasGlobPathChars(filePath: string): boolean { return GLOB_PATH_CHARS.some(char => filePath.includes(char)); } +type PathEntrySplitter = (item: string) => { basePath: string }; + +const TOP_LEVEL_WHITESPACE_RE = /\s/; + +type DelimitedPathSplitMode = "comma" | "semicolon" | "whitespace" | "mixed"; + +function isDelimitedPathSeparator(ch: string, mode: DelimitedPathSplitMode): boolean { + if (mode === "comma") return ch === ","; + if (mode === "semicolon") return ch === ";"; + if (mode === "whitespace") return TOP_LEVEL_WHITESPACE_RE.test(ch); + return ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch); +} + +function hasTopLevelPathDelimiter(entry: string): boolean { + let braceDepth = 0; + for (let i = 0; i < entry.length; i++) { + const ch = entry[i]; + if (ch === "\\" && i + 1 < entry.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth === 0 && (ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch))) { + return true; + } + } + return false; +} + +function splitTopLevelDelimitedPath(entry: string, mode: DelimitedPathSplitMode): string[] { + const parts: string[] = []; + let braceDepth = 0; + let start = 0; + for (let i = 0; i < entry.length; i++) { + const ch = entry[i]; + if (ch === "\\" && i + 1 < entry.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth !== 0 || !isDelimitedPathSeparator(ch, mode)) continue; + parts.push(entry.slice(start, i)); + start = i + 1; + } + parts.push(entry.slice(start)); + return parts; +} + +async function delimitedPathPartResolves(entry: string, cwd: string, splitter: PathEntrySplitter): Promise { + if (isInternalUrlPath(entry)) return true; + const peeled = splitPathAndSel(entry).path; + const { basePath } = splitter(peeled); + const absoluteBasePath = resolveToCwd(basePath, cwd); + try { + await fs.promises.stat(absoluteBasePath); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } +} + +async function tryDelimitedPathSplit( + entry: string, + cwd: string, + splitter: PathEntrySplitter, + mode: DelimitedPathSplitMode, + requireAllParts: boolean, +): Promise { + const rawParts = splitTopLevelDelimitedPath(entry, mode); + if (rawParts.length < 2) return null; + + const parts = rawParts.map(normalizePathLikeInput).filter(part => part.length > 0); + if (parts.length === 0) return null; + if (parts.length < 2 && rawParts.length === parts.length) return null; + + const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter))); + const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean); + return valid ? parts : null; +} + +/** + * Split one path-like entry whose multiple targets were flattened into one + * string. Existing paths are kept intact, so real filenames containing spaces, + * commas, or semicolons win over delimiter recovery. + */ +export async function splitDelimitedPathEntry( + entry: string, + cwd: string, + options: { splitter?: PathEntrySplitter } = {}, +): Promise { + const normalizedEntry = normalizePathLikeInput(entry); + if (!hasTopLevelPathDelimiter(normalizedEntry)) return null; + if (isInternalUrlPath(normalizedEntry)) return null; + + const splitter = options.splitter ?? parseSearchPath; + const peeledEntry = splitPathAndSel(normalizedEntry).path; + if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) { + return null; + } + + return ( + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true)) + ); +} + +/** Expand delimited entries in-place while preserving unsplit entries. */ +export async function expandDelimitedPathEntries( + entries: readonly string[], + cwd: string, + options: { splitter?: PathEntrySplitter } = {}, +): Promise { + const expanded: string[] = []; + for (const entry of entries) { + const normalizedEntry = normalizePathLikeInput(entry); + const split = await splitDelimitedPathEntry(normalizedEntry, cwd, options); + if (split) expanded.push(...split); + else expanded.push(normalizedEntry); + } + return expanded; +} + export interface ParsedSearchPath { basePath: string; glob?: string; @@ -769,7 +908,11 @@ export interface ToolScopeResolution { */ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise { const { rawPaths: inputs, cwd, internalUrlAction } = opts; - const rawPaths = inputs.map(normalizePathLikeInput); + const normalizedRawPaths = inputs.map(normalizePathLikeInput); + if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) { + throw new ToolError("`paths` must contain non-empty paths or globs"); + } + const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd); if (rawPaths.some(rawPath => rawPath.length === 0)) { throw new ToolError("`paths` must contain non-empty paths or globs"); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 8ad9a54f9..559b53fe1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -69,6 +69,7 @@ import { type LineRange, parseLineRanges, resolveReadPath, + splitDelimitedPathEntry, splitInternalUrlSel, splitPathAndSel, } from "./path-utils"; @@ -693,6 +694,50 @@ export class ReadTool implements AgentTool { }); } + async #tryReadDelimitedPaths( + readPath: string, + signal?: AbortSignal, + ): Promise | null> { + const parts = await splitDelimitedPathEntry(readPath, this.session.cwd); + if (!parts) return null; + + const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`; + const notes = [notice]; + const content: Array = []; + let pendingText = notice; + const flushText = () => { + if (pendingText.length === 0) return; + content.push({ type: "text", text: pendingText }); + pendingText = ""; + }; + const appendText = (text: string) => { + pendingText = pendingText.length > 0 ? `${pendingText}\n\n${text}` : text; + }; + + for (const part of parts) { + try { + const result = await this.execute("read-delimited-part", { path: part }, signal); + for (const block of result.content) { + if (block.type === "text") { + appendText(block.text); + continue; + } + flushText(); + content.push(block); + } + } catch (error) { + if (error instanceof ToolAbortError || signal?.aborted) throw error; + const message = error instanceof Error ? error.message : String(error); + const errorNote = `Could not read ${part}: ${message}`; + notes.push(errorNote); + appendText(`[${errorNote}]`); + } + } + flushText(); + + return toolResult({ notes }).content(content).done(); + } + async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise { const candidates = parseArchivePathCandidates(readPath); for (const candidate of candidates) { @@ -1596,6 +1641,8 @@ export class ReadTool implements AgentTool { } if (!suffixResolution) { + const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal); + if (delimitedResult) return delimitedResult; throw new ToolError(`Path '${localReadPath}' not found`); } } else { diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index 897902886..84f2040e0 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -30,6 +30,7 @@ import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { + expandDelimitedPathEntries, hasGlobPathChars, isLineInRanges, type LineRange, @@ -93,29 +94,6 @@ export const SINGLE_FILE_MATCHES = 200; * pagination headroom so the caller can see total file count. */ const INTERNAL_TOTAL_CAP = 2000; -/** - * Detect a `,` that is not inside a `{…}` brace expansion. Used to catch - * `paths: ["a,b"]` mistakes where the caller flattened multiple entries - * into a single string instead of passing a JSON array of strings. - */ -function containsTopLevelComma(entry: string): boolean { - let depth = 0; - for (let i = 0; i < entry.length; i++) { - const ch = entry[i]; - if (ch === "\\" && i + 1 < entry.length) { - i++; - continue; - } - if (ch === "{") depth++; - else if (ch === "}") { - if (depth > 0) depth--; - } else if (ch === "," && depth === 0) { - return true; - } - } - return false; -} - /** * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional * line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …) @@ -146,9 +124,6 @@ function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] { clean = split.path; ranges = parsed; } - if (containsTopLevelComma(clean)) { - throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]'); - } specs.push({ original: entry, clean, ranges }); } return specs; @@ -663,7 +638,7 @@ export class SearchTool implements AgentTool spec.clean); const { diff --git a/packages/coding-agent/src/tools/sqlite-reader.ts b/packages/coding-agent/src/tools/sqlite-reader.ts index 451f2ae5f..37948a48d 100644 --- a/packages/coding-agent/src/tools/sqlite-reader.ts +++ b/packages/coding-agent/src/tools/sqlite-reader.ts @@ -12,6 +12,15 @@ const MAX_QUERY_LIMIT = 500; const MAX_RENDER_WIDTH = 120; const MAX_COLUMN_WIDTH = 40; const MIN_COLUMN_WIDTH = 1; +/** + * Upper bound on rows scanned when counting a table for the listing. SQLite has + * no stored row count, so `COUNT(*)` is a full b-tree scan — multi-second on a + * multi-GB database, and `bun:sqlite` runs it synchronously on the JS thread + * that also drives the TUI, freezing rendering and input. The listing instead + * trusts the planner's `sqlite_stat1` estimate for large tables and only counts + * exactly when a table is provably small, reading at most this many rows. + */ +const ROW_COUNT_PROBE_CAP = 50_000; type SqliteBinding = Exclude>; @@ -26,6 +35,11 @@ interface SqliteCountRow { count: number; } +interface SqliteStat1Row { + tbl: string; + stat: string | null; +} + interface SqliteTableInfoRow { cid: number; name: string; @@ -50,6 +64,23 @@ export type SqliteSelector = export type SqliteRowLookup = { kind: "pk"; column: string; type: string } | { kind: "rowid" }; +/** + * Row count for a table in the listing. + * - `exact`: counted in full (the table is small enough to count cheaply). + * - `estimate`: the planner's `sqlite_stat1` figure; the table is too large to + * scan, so this may be stale. + * - `atLeast`: a lower bound; counting was capped before reaching the end. + */ +export type TableRowCount = + | { kind: "exact"; rows: number } + | { kind: "estimate"; rows: number } + | { kind: "atLeast"; rows: number }; + +export interface SqliteTableSummary { + name: string; + count: TableRowCount; +} + function splitSqliteRemainder(remainder: string): { subPath: string; queryString: string } { const queryIndex = remainder.indexOf("?"); if (queryIndex === -1) { @@ -495,20 +526,61 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit return { kind: "schema", table, sampleLimit: DEFAULT_SCHEMA_SAMPLE_LIMIT }; } -export function listTables(db: Database): { name: string; rowCount: number }[] { +/** + * Reads the planner's per-table row estimate from `sqlite_stat1` (populated by + * `ANALYZE`). The first integer of each `stat` string is the number of rows in + * that index; for a full (non-partial) index it equals the table's row count, + * so the max across a table's entries is the table estimate. Returns an empty + * map when the database was never analyzed. One small indexed read — no scan. + */ +function loadRowEstimates(db: Database): Map { + const estimates = new Map(); + const hasStat1 = db + .prepare, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'", + ) + .get(); + if (!hasStat1) return estimates; + + for (const { tbl, stat } of db.prepare("SELECT tbl, stat FROM sqlite_stat1").all()) { + if (!stat) continue; + const rows = Number.parseInt(stat, 10); + if (!Number.isFinite(rows)) continue; + const prev = estimates.get(tbl); + if (prev === undefined || rows > prev) estimates.set(tbl, rows); + } + return estimates; +} + +/** + * Counts a table while reading at most `cap + 1` rows. Returns an exact count + * when the table holds `cap` rows or fewer, otherwise a lower bound of `cap`. + * Bounds the worst-case scan so a stale or missing estimate can never trigger a + * full-table scan on the JS thread. + */ +function probeRowCount(db: Database, table: string, cap: number): TableRowCount { + const sql = `SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteSqliteIdentifier(table)} LIMIT ${cap + 1})`; + const counted = db.prepare(sql).get()?.count ?? 0; + return counted > cap ? { kind: "atLeast", rows: cap } : { kind: "exact", rows: counted }; +} + +export function listTables(db: Database, options: { probeCap?: number } = {}): SqliteTableSummary[] { + const cap = options.probeCap ?? ROW_COUNT_PROBE_CAP; const names = db .prepare, []>( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name COLLATE NOCASE", ) .all(); + const estimates = loadRowEstimates(db); return names.map(({ name }) => { - const countRow = - db.prepare(`SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(name)}`).get() ?? null; - return { - name, - rowCount: countRow?.count ?? 0, - }; + const estimate = estimates.get(name); + // Trust the planner only when it says the table is too large to count + // cheaply; otherwise count exactly (bounded), which also corrects a + // stale-low estimate without ever scanning more than `cap` rows. + const count: TableRowCount = + estimate !== undefined && estimate > cap ? { kind: "estimate", rows: estimate } : probeRowCount(db, name, cap); + return { name, count }; }); } @@ -679,13 +751,24 @@ export function deleteRowByRowId(db: Database, table: string, key: string): numb return statement.run(binding).changes; } -export function renderTableList(tables: { name: string; rowCount: number }[]): string { +function formatRowCount(count: TableRowCount): string { + switch (count.kind) { + case "exact": + return `${count.rows} rows`; + case "estimate": + return `~${count.rows} rows`; + case "atLeast": + return `${count.rows}+ rows`; + } +} + +export function renderTableList(tables: SqliteTableSummary[]): string { if (tables.length === 0) { return "(no tables)"; } return tables - .map(table => truncateToWidth(replaceTabs(`${table.name} (${table.rowCount} rows)`), MAX_RENDER_WIDTH)) + .map(table => truncateToWidth(replaceTabs(`${table.name} (${formatRowCount(table.count)})`), MAX_RENDER_WIDTH)) .join("\n"); } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 22438ee0a..5ea555ad1 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -119,7 +119,7 @@ async function readImageViaPowerShell(): Promise { if (!b64) return null; const bytes = Buffer.from(b64, "base64"); if (bytes.byteLength === 0) return null; - return { data: new Uint8Array(bytes), mimeType: "image/png" }; + return { data: bytes, mimeType: "image/png" }; } catch { return null; } @@ -154,3 +154,40 @@ export async function readImageFromClipboard(): Promise { return (await native.readImageFromClipboard()) ?? null; } + +/** + * Read plain text from the system clipboard. + */ +export async function readTextFromClipboard(): Promise { + try { + const p = process.platform; + if (p === "darwin") { + return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString(); + } + if (p === "win32") { + return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', { + encoding: "utf8", + timeout: 2000, + }).toString(); + } + if (process.env.TERMUX_VERSION) { + return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString(); + } + const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); + const hasX11Display = Boolean(process.env.DISPLAY); + if (hasWaylandDisplay) { + try { + return execSync("wl-paste --type text/plain --no-newline", { encoding: "utf8", timeout: 2000 }).toString(); + } catch { + if (hasX11Display) { + return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString(); + } + } + } else if (hasX11Display) { + return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString(); + } + } catch (error) { + logger.warn("clipboard: failed to read clipboard text", { error: String(error) }); + } + return ""; +} diff --git a/packages/coding-agent/src/utils/open.ts b/packages/coding-agent/src/utils/open.ts index 2395bf871..db4090e5d 100644 --- a/packages/coding-agent/src/utils/open.ts +++ b/packages/coding-agent/src/utils/open.ts @@ -1,3 +1,36 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; +import * as piUtils from "@oh-my-pi/pi-utils"; + +const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/; + +function getExistingWslLocalPath(urlOrPath: string): string | undefined { + if ( + process.platform !== "linux" || + !(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) || + !piUtils.$which("wslview") + ) { + return undefined; + } + + try { + const localPath = urlOrPath.startsWith("file://") + ? url.fileURLToPath(urlOrPath) + : URL_SCHEME_PATTERN.test(urlOrPath) + ? undefined + : path.resolve(urlOrPath); + if (!localPath || !fs.existsSync(localPath)) return undefined; + + const result = Bun.spawnSync(["wslpath", "-w", localPath], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + + return result.stdout.toString().trim() || undefined; + } catch { + return undefined; + } +} + /** Open a URL or file path in the default browser/application. Best-effort, never throws. */ export function openPath(urlOrPath: string): void { let cmd: string[]; @@ -8,9 +41,11 @@ export function openPath(urlOrPath: string): void { case "win32": cmd = ["rundll32", "url.dll,FileProtocolHandler", urlOrPath]; break; - default: - cmd = ["xdg-open", urlOrPath]; + default: { + const wslPath = getExistingWslLocalPath(urlOrPath); + cmd = wslPath ? ["wslview", wslPath] : ["xdg-open", urlOrPath]; break; + } } try { Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); diff --git a/packages/coding-agent/src/web/kagi.ts b/packages/coding-agent/src/web/kagi.ts index 06ac5df5c..844feafe0 100644 --- a/packages/coding-agent/src/web/kagi.ts +++ b/packages/coding-agent/src/web/kagi.ts @@ -1,42 +1,92 @@ +/** + * Kagi API Client + * + * Implements the Kagi V1 Search API (POST /api/v1/search), the public-preview + * successor to the sunset V0 endpoint. Authentication is resolved exclusively + * through the shared {@link AuthStorage} broker (Bearer token), and responses + * are categorized result buckets rather than the legacy flat object array. + */ import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { withHardTimeout } from "./search/providers/utils"; -const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search"; +const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search"; -interface KagiSearchResultObject { - t: 0; +// --------------------------------------------------------------------------- +// Request / Response Types +// --------------------------------------------------------------------------- + +/** V1 search request body. */ +export interface KagiSearchRequest { + query: string; + /** Workflow mode: "search" | "research". */ + workflow?: string; + /** Number of results (1-100). */ + limit?: number; + /** Lens identifier (e.g. "news", "reddit"). */ + lens?: string; + /** Time-based filters as ISO date strings (YYYY-MM-DD). */ + filters?: { + after?: string; + before?: string; + }; +} + +/** Individual V1 result item. */ +export interface KagiSearchResultItem { url: string; title: string; snippet?: string; - published?: string; + /** ISO timestamp or relative string ("2h ago"). */ + time?: string; + /** Thumbnail image. */ + image?: { url: string; height?: number; width?: number }; + /** Extra metadata key-value pairs. */ + props?: Record; } -interface KagiRelatedSearchesObject { - t: 1; - list: string[]; +/** V1 categorizes results into named buckets; only consumed buckets are typed. */ +export interface KagiSearchData { + search?: KagiSearchResultItem[]; + video?: KagiSearchResultItem[]; + news?: KagiSearchResultItem[]; + infobox?: KagiSearchResultItem[]; + adjacent_question?: KagiSearchResultItem[]; + related_search?: KagiSearchResultItem[]; + direct_answer?: KagiSearchResultItem[]; } -type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject; - -interface KagiErrorEntry { +/** V1 error entry. */ +export interface KagiErrorEntry { code?: number; + url?: string; + message?: string; msg?: string; + location?: string; } -interface KagiSearchResponse { - meta: { - id: string; +/** V1 success response. */ +export interface KagiSearchResponse { + meta?: { + trace?: string; + id?: string; + ms?: number; }; - data: KagiSearchObject[]; + data?: KagiSearchData; error?: KagiErrorEntry[]; } -interface KagiErrorResponse { +/** V1 error response. */ +export interface KagiErrorResponse { + meta?: Record; error?: string | KagiErrorEntry[]; message?: string; detail?: string; } +// --------------------------------------------------------------------------- +// Error Handling +// --------------------------------------------------------------------------- + export class KagiApiError extends Error { readonly statusCode?: number; @@ -64,9 +114,11 @@ function extractKagiErrorMessage(payload: unknown): string | null { if (Array.isArray(record.error)) { for (const entry of record.error) { if (!entry || typeof entry !== "object") continue; - const message = (entry as Record).msg; - if (typeof message === "string" && message.trim().length > 0) { - return message.trim(); + const e = entry as Record; + for (const value of [e.message, e.msg]) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } } } } @@ -82,21 +134,26 @@ function createKagiApiError(statusCode: number, detail?: string): KagiApiError { } function parseKagiErrorResponse(statusCode: number, responseText: string): KagiApiError { - const trimmedResponseText = responseText.trim(); - if (trimmedResponseText.length === 0) { + const trimmed = responseText.trim(); + if (trimmed.length === 0) { return createKagiApiError(statusCode); } try { - const payload = JSON.parse(trimmedResponseText) as KagiErrorResponse; - return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmedResponseText); + const payload = JSON.parse(trimmed) as KagiErrorResponse; + return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmed); } catch { - return createKagiApiError(statusCode, trimmedResponseText); + return createKagiApiError(statusCode, trimmed); } } +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + export interface KagiSearchOptions { limit?: number; + recency?: "day" | "week" | "month" | "year"; sessionId?: string; signal?: AbortSignal; } @@ -112,6 +169,7 @@ export interface KagiSearchResult { requestId: string; sources: KagiSearchSource[]; relatedQuestions: string[]; + answer?: string; } export async function findKagiApiKey( @@ -122,11 +180,65 @@ export async function findKagiApiKey( return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null; } -function getAuthHeaders(apiKey: string): Record { - return { - Authorization: `Bot ${apiKey}`, - Accept: "application/json", +/** + * Compute a YYYY-MM-DD date string `recency` units before now, in UTC. + * UTC keeps the recency window deterministic regardless of host timezone and + * matches Kagi's date-formatted `filters.after`. Date setters handle month + * drift (Mar 31 −1mo → Feb 28/29) and leap years correctly. + */ +function recencyToDate(recency: "day" | "week" | "month" | "year"): string { + const d = new Date(); + switch (recency) { + case "day": + d.setUTCDate(d.getUTCDate() - 1); + break; + case "week": + d.setUTCDate(d.getUTCDate() - 7); + break; + case "month": + d.setUTCMonth(d.getUTCMonth() - 1); + break; + case "year": + d.setUTCFullYear(d.getUTCFullYear() - 1); + break; + } + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +function buildRequestBody(query: string, options: KagiSearchOptions): KagiSearchRequest { + const req: KagiSearchRequest = { + query, + workflow: "search", + limit: options.limit, }; + + if (options.recency) { + req.filters = { after: recencyToDate(options.recency) }; + } + + return req; +} + +/** Push every item in a result bucket as a source, with an optional title tag. */ +function collectSources(sources: KagiSearchSource[], items: KagiSearchResultItem[] | undefined, tag?: string): void { + if (!items) return; + for (const item of items) { + sources.push({ + title: tag ? `${tag} ${item.title}` : item.title, + url: item.url, + snippet: item.snippet, + publishedDate: item.time, + }); + } +} + +/** Pull a related/adjacent question from an item's props or fall back to title. */ +function questionOf(item: KagiSearchResultItem): string | undefined { + const q = item.props?.question ?? item.props?.query ?? item.title; + return typeof q === "string" && q.length > 0 ? q : undefined; } export async function searchWithKagi( @@ -139,45 +251,52 @@ export async function searchWithKagi( throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'."); } - const requestUrl = new URL(KAGI_SEARCH_URL); - requestUrl.searchParams.set("q", query); - if (options.limit !== undefined) { - requestUrl.searchParams.set("limit", String(options.limit)); - } - - const response = await fetch(requestUrl, { - headers: getAuthHeaders(apiKey), + const response = await fetch(KAGI_SEARCH_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(buildRequestBody(query, options)), signal: withHardTimeout(options.signal), }); + if (!response.ok) { throw parseKagiErrorResponse(response.status, await response.text()); } const payload = (await response.json()) as KagiSearchResponse; if (payload.error && payload.error.length > 0) { - const firstError = payload.error[0]; - throw createKagiApiError(firstError.code ?? response.status, extractKagiErrorMessage(payload) ?? undefined); + const first = payload.error[0]; + throw createKagiApiError(first.code ?? response.status, extractKagiErrorMessage(payload) ?? first.message); } + const data = payload.data; const sources: KagiSearchSource[] = []; const relatedQuestions: string[] = []; - for (const item of payload.data) { - if (item.t === 0) { - sources.push({ - title: item.title, - url: item.url, - snippet: item.snippet, - publishedDate: item.published ?? undefined, - }); - } else if (item.t === 1) { - relatedQuestions.push(...item.list); - } + collectSources(sources, data?.search); + collectSources(sources, data?.video, "[Video]"); + collectSources(sources, data?.news, "[News]"); + collectSources(sources, data?.infobox, "[Info]"); + + for (const item of data?.adjacent_question ?? []) { + const q = questionOf(item); + if (q) relatedQuestions.push(q); + } + for (const item of data?.related_search ?? []) { + const q = questionOf(item); + if (q) relatedQuestions.push(q); } + const directAnswer = data?.direct_answer?.[0]; + const answer = directAnswer ? (directAnswer.snippet ?? directAnswer.title) : undefined; + return { - requestId: payload.meta.id, + requestId: payload.meta?.trace ?? payload.meta?.id ?? "", sources, relatedQuestions, + answer, }; } diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index 8e245c7bb..07a2df740 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -255,7 +255,7 @@ export async function searchAnthropic( const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, { signal: params.signal, }); - if (apiKey) auth = buildAnthropicAuthConfig(apiKey); + if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl); } if (!auth) { diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 2397aa2d4..bb25d761a 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -8,7 +8,6 @@ */ import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai"; import { settings } from "../../../config/settings"; -import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client"; import type { SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; @@ -32,6 +31,12 @@ export interface ExaSearchParams { start_published_date?: string; end_published_date?: string; signal?: AbortSignal; + /** + * Credential source. Resolved before falling back to `EXA_API_KEY` so + * Exa works when the key is stored via the broker/auth pipeline. + */ + authStorage?: AuthStorage; + sessionId?: string; } interface ExaSearchResult { @@ -52,72 +57,6 @@ interface ExaSearchResponse { searchTime?: number; } -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null) return null; - return value as Record; -} - -function parseOptionalField(section: string, label: string): string | null | undefined { - const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`); - const match = section.match(regex); - if (!match) return undefined; - const value = match[1].trim(); - return value.length > 0 ? value : null; -} - -function parseTextField(section: string): string | null | undefined { - const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/); - if (!match) return undefined; - const value = match[1].trim(); - return value.length > 0 ? value : null; -} - -function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null { - const root = asRecord(payload); - if (!root) return null; - - const content = root.content; - if (!Array.isArray(content)) return null; - - const textBlocks = content - .map(item => { - const part = asRecord(item); - const text = typeof part?.text === "string" ? part.text : ""; - return text.replace(/\r\n?/g, "\n").trim(); - }) - .filter(text => text.length > 0); - - if (textBlocks.length === 0) return null; - - const sections = textBlocks - .join("\n\n") - .split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/) - .map(section => section.trim()) - .filter(section => section.startsWith("Title:")); - - const results: ExaSearchResult[] = []; - for (const section of sections) { - const title = parseOptionalField(section, "Title"); - const url = parseOptionalField(section, "URL"); - const author = parseOptionalField(section, "Author"); - const publishedDate = parseOptionalField(section, "Published Date"); - const text = parseTextField(section); - - if (!title && !url && !text) continue; - - results.push({ - title: title ?? undefined, - url: url ?? undefined, - author: author ?? undefined, - publishedDate: publishedDate ?? undefined, - text: text ?? undefined, - }); - } - - if (results.length === 0) return null; - return { results }; -} - export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType { if (!type) return "auto"; if (type === "keyword") return "fast"; @@ -195,24 +134,17 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise; } -async function callExaMcpSearch(params: ExaSearchParams): Promise { - const response = await callExaTool("web_search_exa", { ...params }, findApiKey()); - if (isSearchResponse(response)) { - return response as ExaSearchResponse; - } - - const parsed = parseExaMcpTextPayload(response); - if (parsed) { - return parsed; - } - - throw new Error("Exa MCP search returned unexpected response shape."); -} - /** Execute Exa web search */ export async function searchExa(params: ExaSearchParams): Promise { - const apiKey = getEnvApiKey("exa"); - const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params); + const storedKey = params.authStorage + ? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal }) + : undefined; + const apiKey = storedKey ?? getEnvApiKey("exa"); + if (!apiKey) { + throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'."); + } + + const response = await callExaSearch(apiKey, params); // Convert to unified SearchResponse const sources: SearchSource[] = []; @@ -250,15 +182,15 @@ export class ExaProvider extends SearchProvider { readonly id = "exa"; readonly label = "Exa"; - isAvailable(_authStorage: AuthStorage): boolean { + isAvailable(authStorage: AuthStorage): boolean { try { if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) { return false; } } catch { - // Settings not initialized; fall through to public MCP availability + // Settings may be unavailable before CLI initialization; credential availability is still authoritative. } - return true; + return authStorage.hasAuth("exa"); } search(params: SearchParams): Promise { @@ -266,6 +198,8 @@ export class ExaProvider extends SearchProvider { query: params.query, num_results: params.numSearchResults ?? params.limit, signal: params.signal, + authStorage: params.authStorage, + sessionId: params.sessionId, }); } } diff --git a/packages/coding-agent/src/web/search/providers/kagi.ts b/packages/coding-agent/src/web/search/providers/kagi.ts index b47285436..cc2ac223b 100644 --- a/packages/coding-agent/src/web/search/providers/kagi.ts +++ b/packages/coding-agent/src/web/search/providers/kagi.ts @@ -19,6 +19,7 @@ const MAX_NUM_RESULTS = 40; export async function searchKagi(params: { query: string; num_results?: number; + recency?: SearchParams["recency"]; signal?: AbortSignal; authStorage: AuthStorage; sessionId?: string; @@ -30,6 +31,7 @@ export async function searchKagi(params: { params.query, { limit: numResults, + recency: params.recency, sessionId: params.sessionId, signal: params.signal, }, @@ -41,6 +43,7 @@ export async function searchKagi(params: { sources: toSearchSources(result.sources, numResults), relatedQuestions: result.relatedQuestions.length > 0 ? result.relatedQuestions : undefined, requestId: result.requestId, + answer: result.answer, }; } catch (err) { if (err instanceof KagiApiError) { @@ -67,6 +70,7 @@ export class KagiProvider extends SearchProvider { return searchKagi({ query: params.query, num_results: params.numSearchResults ?? params.limit, + recency: params.recency, signal: params.signal, authStorage: params.authStorage, sessionId: params.sessionId, diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index 7a501060c..c6c10e1cb 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -188,6 +188,128 @@ describe("ACP event mapper", () => { expect(doneUpdates).toEqual([]); }); + it("preserves command text when a new command tool is started", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-command-start", + toolName: "bash", + args: { command: "npm run check" }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + sessionUpdate: string; + content?: Array<{ type: string; content?: { type: string; text?: string } }>; + }; + expect(update.sessionUpdate).toBe("tool_call"); + expect(update.content).toContainEqual({ type: "content", content: { type: "text", text: "$ npm run check" } }); + }); + + it("uses command text for a new command tool even when intent is generic", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-command-start-generic-intent", + toolName: "bash", + args: { command: "echo hi" }, + intent: "Running command", + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + title: string; + content?: Array<{ type: string; content?: { type: string; text?: string } }>; + }; + expect(update.title).toBe("$ echo hi"); + expect(update.content).toContainEqual({ type: "content", content: { type: "text", text: "$ echo hi" } }); + }); + + it("preserves eval source when a new eval tool is started", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-eval-start", + toolName: "eval", + args: { cells: [{ language: "js", title: "sum", code: "return 1 + 1;" }] }, + intent: "sum", + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + sessionUpdate: string; + title: string; + kind?: string; + status?: string; + rawInput?: unknown; + content?: Array<{ type: string; content?: { type: string; text?: string } }>; + }; + expect(update.sessionUpdate).toBe("tool_call"); + expect(update.title).toBe("[js] sum\nreturn 1 + 1;"); + expect(update.kind).toBe("execute"); + expect(update.status).toBe("pending"); + expect(update.rawInput).toEqual({ cells: [{ language: "js", title: "sum", code: "return 1 + 1;" }] }); + expect(update.content).toContainEqual({ + type: "content", + content: { type: "text", text: "[js] sum\nreturn 1 + 1;" }, + }); + }); + + it("builds eval source content from valid cells only", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-eval-mixed-cells", + toolName: "eval", + args: { + cells: [null, {}, { code: "" }, { code: "x" }, { language: "py", code: "y" }], + }, + intent: "evaluating", + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + title: string; + content?: Array<{ type: string; content?: { type: string; text?: string } }>; + }; + expect(update.title).toBe("[?]\nx\n[py]\ny"); + expect(update.content).toEqual([{ type: "content", content: { type: "text", text: "[?]\nx\n[py]\ny" } }]); + }); + + it("limits eval source before emitting visible tool-call text", () => { + const source = "x".repeat(4_100); + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-eval-long-source", + toolName: "eval", + args: { cells: [{ language: "js", code: source }] }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + title: string; + content?: Array<{ type: string; content?: { type: string; text?: string } }>; + }; + expect(update.title).toHaveLength(4_000); + expect(update.title.endsWith("…")).toBe(true); + expect(update.content).toEqual([{ type: "content", content: { type: "text", text: update.title } }]); + }); it("emits a diff ToolCallContent for each per-file edit result", () => { const updates = mapAgentSessionEventToAcpSessionUpdates( { @@ -547,7 +669,7 @@ describe("ACP event mapper", () => { }; expect(update.sessionUpdate).toBe("tool_call"); expect(update.toolCallId).toBe("toolu_bash_1"); - expect(update.title).toBe("bash: npm run check"); + expect(update.title).toBe("$ npm run check"); expect(update.kind).toBe("execute"); expect(update.status).toBe("pending"); expect(update.rawInput).toEqual({ command: "npm run check", cwd: "/repo" }); @@ -676,7 +798,7 @@ describe("ACP event mapper", () => { expect(update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "toolu_replay_1", - title: "bash: npm test", + title: "$ npm test", kind: "execute", status: "completed", rawInput: { command: "npm test", cwd: "/repo" }, @@ -741,7 +863,7 @@ describe("ACP event mapper", () => { expect(replayArgs.args).toBe(rawArgs); expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); expect(update).toMatchObject({ - title: "bash: bun test", + title: "$ bun test", status: "completed", rawInput: rawArgs, content: [{ type: "content", content: { type: "text", text: "$ bun test" } }], diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 2b19d072e..2833ae51b 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -155,6 +155,86 @@ async function closeTransport(writable: WritableStream): Promise } describe("ACP lazy startup", () => { + it("keeps ACP background jobs disabled by default and preserves explicit opt-ins", async () => { + const { runRootCommand } = await import("../src/main"); + + type ObservedBackgroundSettings = { + asyncEnabled: boolean; + asyncMaxJobs: number; + bashAutoBackground: boolean; + bashAutoBackgroundThresholdMs: number; + }; + + const runAcpStartup = async (settings: Settings): Promise => { + using tempDir = TempDir.createSync("@omp-acp-background-settings-"); + const cwd = tempDir.path(); + const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); + let observed: ObservedBackgroundSettings | undefined; + const stopMessage = "stop test ACP mode"; + try { + await runRootCommand( + { + mode: "acp", + messages: [], + fileArgs: [], + unknownFlags: new Map(), + noSkills: true, + noRules: true, + noTools: true, + noLsp: true, + sessionDir: cwd, + }, + [], + { + discoverAuthStorage: async () => authStorage, + settings, + runAcpMode: async () => { + observed = { + asyncEnabled: settings.get("async.enabled"), + asyncMaxJobs: settings.get("async.maxJobs"), + bashAutoBackground: settings.get("bash.autoBackground.enabled"), + bashAutoBackgroundThresholdMs: settings.get("bash.autoBackground.thresholdMs"), + }; + throw new Error(stopMessage); + }, + }, + ); + } catch (error) { + if (!(error instanceof Error) || error.message !== stopMessage) { + throw error; + } + } finally { + authStorage.close(); + } + + if (!observed) { + throw new Error("Expected ACP mode to start"); + } + return observed; + }; + + await expect(runAcpStartup(Settings.isolated())).resolves.toEqual({ + asyncEnabled: false, + asyncMaxJobs: 100, + bashAutoBackground: false, + bashAutoBackgroundThresholdMs: 60000, + }); + await expect( + runAcpStartup( + Settings.isolated({ + "async.enabled": true, + "async.maxJobs": 7, + "bash.autoBackground.enabled": true, + "bash.autoBackground.thresholdMs": 1234, + }), + ), + ).resolves.toEqual({ + asyncEnabled: true, + asyncMaxJobs: 7, + bashAutoBackground: true, + bashAutoBackgroundThresholdMs: 1234, + }); + }); it("answers initialize before creating the first AgentSession", async () => { const clientToAgent = new TransformStream(); const agentToClient = new TransformStream(); diff --git a/packages/coding-agent/test/agent-session-model-persistence.test.ts b/packages/coding-agent/test/agent-session-model-persistence.test.ts index e5241ae5d..e371b5bf2 100644 --- a/packages/coding-agent/test/agent-session-model-persistence.test.ts +++ b/packages/coding-agent/test/agent-session-model-persistence.test.ts @@ -4,6 +4,7 @@ import { Agent } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { type CreateAgentSessionResult, createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -40,10 +41,44 @@ describe("AgentSession model persistence", () => { return `${model.provider}/${model.id}`; } + async function writeRoleModelSession( + defaultRoleValue: string, + smolRoleValue: string, + lastRole = "smol", + ): Promise { + const targetSessionFile = path.join(tempDir.path(), `target-${Bun.nanoseconds()}.jsonl`); + const timestamp = "2026-06-01T00:00:00.000Z"; + await Bun.write( + targetSessionFile, + `${[ + { type: "session", version: 3, id: "target-session", timestamp, cwd: tempDir.path() }, + { + type: "model_change", + id: "default-model", + parentId: null, + timestamp, + model: defaultRoleValue, + role: "default", + }, + { + type: "model_change", + id: "smol-model", + parentId: "default-model", + timestamp, + model: smolRoleValue, + role: lastRole, + }, + ] + .map(entry => JSON.stringify(entry)) + .join("\n")}\n`, + ); + return targetSessionFile; + } async function createSession(options?: { initialModel?: Model; selectInitialModel?: (availableModels: Model[]) => Model; modelRoles?: Record; + persist?: boolean; }): Promise<{ modelRegistry: ModelRegistry; settings: Settings; session: AgentSession }> { const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${authStorages.length}.db`)); authStorages.push(authStorage); @@ -78,7 +113,9 @@ describe("AgentSession model persistence", () => { } session = new AgentSession({ agent, - sessionManager: SessionManager.inMemory(), + sessionManager: options?.persist + ? SessionManager.create(tempDir.path(), path.join(tempDir.path(), `active-${authStorages.length}`)) + : SessionManager.inMemory(), settings: sessionSettings, modelRegistry, }); @@ -86,6 +123,37 @@ describe("AgentSession model persistence", () => { return { modelRegistry, settings: sessionSettings, session }; } + async function createStartupResumeSession( + targetSessionFile: string, + settings: Settings = Settings.isolated(), + ): Promise { + const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${authStorages.length}.db`)); + authStorages.push(authStorage); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry( + authStorage, + path.join(tempDir.path(), `models-${authStorages.length}.yml`), + ); + const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir.path(), "startup")); + const result = await createAgentSession({ + cwd: tempDir.path(), + agentDir: tempDir.path(), + authStorage, + modelRegistry, + sessionManager, + settings, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + }); + session = result.session; + return result; + } it("switches the active model without persisting by default", async () => { const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); const nextModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); @@ -187,4 +255,92 @@ describe("AgentSession model persistence", () => { expect(modelValue(activeModel)).toBe(modelValue(result.model)); expect(created.settings.getModelRole("default")).toBe(defaultRoleValue); }); + + it("restores the last active role model when switching sessions", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const smolModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const smolRoleValue = modelValue(smolModel); + + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, smolRoleValue); + + const created = await createSession({ + initialModel: defaultModel, + modelRoles: { default: defaultRoleValue, smol: smolRoleValue }, + persist: true, + }); + + await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true); + expect(created.session.model?.id).toBe(smolModel.id); + }); + + it("restores the last active role model during startup resume", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const smolModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const smolRoleValue = modelValue(smolModel); + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, smolRoleValue); + + const result = await createStartupResumeSession(targetSessionFile); + + expect(result.session.model?.id).toBe(smolModel.id); + }); + + it("falls back to the saved default model when switch-session role restore is unavailable", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const previousModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, "anthropic/not-loaded-anymore"); + + const created = await createSession({ + initialModel: previousModel, + modelRoles: { default: defaultRoleValue }, + persist: true, + }); + + await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true); + expect(created.session.model?.id).toBe(defaultModel.id); + }); + + it("restores the saved default model when switch-session last role is temporary", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const temporaryModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, modelValue(temporaryModel), "temporary"); + + const created = await createSession({ + initialModel: temporaryModel, + modelRoles: { default: defaultRoleValue }, + persist: true, + }); + + await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true); + expect(created.session.model?.id).toBe(defaultModel.id); + }); + + it("falls back to the saved default model when startup role restore is unavailable", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const settingsFallbackModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, "anthropic/not-loaded-anymore"); + const settings = Settings.isolated(); + settings.setModelRole("default", modelValue(settingsFallbackModel)); + + const result = await createStartupResumeSession(targetSessionFile, settings); + + expect(result.session.model?.id).toBe(defaultModel.id); + }); + + it("restores the saved default model when startup last role is temporary", async () => { + const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const temporaryModel = getAnthropicModelOrThrow("claude-sonnet-4-6"); + const defaultRoleValue = modelValue(defaultModel); + const targetSessionFile = await writeRoleModelSession(defaultRoleValue, modelValue(temporaryModel), "temporary"); + const settings = Settings.isolated(); + settings.setModelRole("default", modelValue(temporaryModel)); + + const result = await createStartupResumeSession(targetSessionFile, settings); + + expect(result.session.model?.id).toBe(defaultModel.id); + }); }); diff --git a/packages/coding-agent/test/agent-session-python-cleanup.test.ts b/packages/coding-agent/test/agent-session-python-cleanup.test.ts index d47e2382d..478fc5fb7 100644 --- a/packages/coding-agent/test/agent-session-python-cleanup.test.ts +++ b/packages/coding-agent/test/agent-session-python-cleanup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -92,6 +92,15 @@ const mockPositiveSleepsImmediate = () => { return realSleep(duration ?? 0); }); }; + +const expectSleepNear = (sleepSpy: Mock, targetMs: number) => { + const minMs = targetMs - 100; + expect( + sleepSpy.mock.calls.some( + ([duration]) => typeof duration === "number" && duration >= minMs && duration <= targetMs, + ), + ).toBe(true); +}; const createSession = async ( tempDir: string, cwd: string, @@ -415,7 +424,7 @@ describe("AgentSession python cleanup", () => { const [toolResult] = await Promise.all([toolExecution, disposeSession]); - expect(sleepSpy).toHaveBeenCalledWith(3000); + expectSleepNear(sleepSpy, 3000); expect(disposed).toBe(true); expect(toolExecutionSettled).toBe(true); @@ -460,7 +469,7 @@ describe("AgentSession python cleanup", () => { firstDisposed = true; }); await disposeFirst; - expect(sleepSpy).toHaveBeenCalledWith(3000); + expectSleepNear(sleepSpy, 3000); expect(firstDisposed).toBe(true); expect(firstExecutionSettled).toBe(false); @@ -708,7 +717,7 @@ describe("AgentSession python cleanup", () => { const sleepSpy = mockPositiveSleepsImmediate(); await session.dispose(); - expect(sleepSpy).toHaveBeenCalledWith(3000); + expectSleepNear(sleepSpy, 3000); const [firstResult, secondResult] = await Promise.all([firstExecution, secondExecution]); expect(firstResult.cancelled).toBe(true); diff --git a/packages/coding-agent/test/cli-help-load-order.test.ts b/packages/coding-agent/test/cli-help-load-order.test.ts deleted file mode 100644 index 5de9743bc..000000000 --- a/packages/coding-agent/test/cli-help-load-order.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; - -const repoRoot = path.resolve(import.meta.dir, "..", "..", ".."); -const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); - -let cleanupRoot: string | undefined; - -async function readStream(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let text = ""; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - text += decoder.decode(value, { stream: true }); - } - return text + decoder.decode(); - } finally { - reader.releaseLock(); - } -} - -afterEach(async () => { - if (cleanupRoot) { - await fs.rm(cleanupRoot, { recursive: true, force: true }); - cleanupRoot = undefined; - } -}); - -describe("CLI help load order", () => { - it("loads the root help command without tripping config/model-registry cycles", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-help-load-order-")); - cleanupRoot = root; - const home = path.join(root, "home"); - const xdg = path.join(root, "xdg"); - const agentDir = path.join(root, "agent"); - await fs.mkdir(home, { recursive: true }); - await fs.mkdir(xdg, { recursive: true }); - await fs.mkdir(agentDir, { recursive: true }); - - const proc = Bun.spawn([process.execPath, cliEntry, "--help"], { - cwd: repoRoot, - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: xdg, - XDG_DATA_HOME: xdg, - PI_CODING_AGENT_DIR: agentDir, - PI_NO_TITLE: "1", - NO_COLOR: "1", - }, - }); - - const [, , exitCode] = await Promise.all([ - readStream(proc.stdout as ReadableStream), - readStream(proc.stderr as ReadableStream), - proc.exited, - ]); - - expect(exitCode).toBe(0); - }); -}); diff --git a/packages/coding-agent/test/cli-hide-thinking-flag.test.ts b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts new file mode 100644 index 000000000..4b4dcf949 --- /dev/null +++ b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { Effort } from "@oh-my-pi/pi-ai"; +import { parseArgs } from "../src/cli/args"; + +describe("parseArgs — --hide-thinking flag", () => { + it("parses --hide-thinking as a boolean flag", () => { + const result = parseArgs(["--hide-thinking"]); + expect(result.hideThinking).toBe(true); + }); + + it("defaults hideThinking to undefined when flag is not provided", () => { + const result = parseArgs([]); + expect(result.hideThinking).toBeUndefined(); + }); + + it("parses --hide-thinking with other flags", () => { + const result = parseArgs(["--hide-thinking", "--model", "opus", "hello"]); + expect(result.hideThinking).toBe(true); + expect(result.model).toBe("opus"); + expect(result.messages).toContain("hello"); + }); + + it("parses --hide-thinking with --thinking flag (both can coexist)", () => { + const result = parseArgs(["--hide-thinking", "--thinking", "xhigh"]); + expect(result.hideThinking).toBe(true); + expect(result.thinking).toBe(Effort.XHigh); + }); + + it("parses --hide-thinking in any position", () => { + const result1 = parseArgs(["--hide-thinking", "prompt"]); + const result2 = parseArgs(["prompt", "--hide-thinking"]); + const result3 = parseArgs(["--model", "opus", "--hide-thinking", "prompt"]); + + expect(result1.hideThinking).toBe(true); + expect(result2.hideThinking).toBe(true); + expect(result3.hideThinking).toBe(true); + }); + + it("does not consume a value after --hide-thinking", () => { + const result = parseArgs(["--hide-thinking", "--model", "opus"]); + expect(result.hideThinking).toBe(true); + expect(result.model).toBe("opus"); + expect(result.messages).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index 0f7e23c24..9c065ecc8 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -350,7 +350,7 @@ describe("listClaudePluginRoots", () => { const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.length).toBeGreaterThan(0); - const found = result.all.find(skill => skill.name === "manifest-skills:manifest-skill"); + const found = result.all.find(skill => skill.name === "manifest-skill"); expect(found).toBeDefined(); expect(found?.path).toContain(path.join(".claude", "skills", "manifest-skill", "SKILL.md")); @@ -507,7 +507,7 @@ describe("listClaudePluginRoots", () => { const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings[0]).toContain("Ignoring skills path outside plugin root"); - const found = result.all.find(skill => skill.name === "manifest-skills-outside:outside-skill"); + const found = result.all.find(skill => skill.name === "outside-skill"); expect(found).toBeUndefined(); }); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts new file mode 100644 index 000000000..fcc6136f3 --- /dev/null +++ b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts @@ -0,0 +1,148 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as url from "node:url"; +import { loadLegacyPiModule } from "../../src/extensibility/plugins/legacy-pi-compat"; + +// Issue #1674: legacy Pi extensions load browser-UI assets (HTML/CSS) at module +// init via `readFileSync(join(__dirname, "ui.html"))`. The compat layer must run +// the extension from its real on-disk location so `import.meta.url` (and thus +// `__dirname`) points at the extension's own directory — no temp-directory +// mirror, no asset copying. These tests pin that contract end-to-end through the +// public `loadLegacyPiModule` entry point. + +const tempRoots: string[] = []; + +afterAll(async () => { + for (const dir of tempRoots) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +async function writePackage(files: Record): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-inplace-")); + tempRoots.push(dir); + for (const rel in files) { + const abs = path.join(dir, rel); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, files[rel], "utf8"); + } + return dir; +} + +describe("legacy-pi in-place module loading (issue #1674)", () => { + it("reads __dirname-relative HTML assets from the real extension directory", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "asset-ext", version: "1.0.0" }), + "ui.html": "PLAN-UI", + "index.ts": [ + 'import { readFileSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + 'import * as path from "node:path";', + "const here = path.dirname(fileURLToPath(import.meta.url));", + "export const dirName = here;", + 'export const html = readFileSync(path.join(here, "ui.html"), "utf8");', + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { dirName: string; html: string }; + + // The asset resolves because the module runs in place — its computed + // __dirname is the extension's real directory, not a mirror temp root. + // (Bun realpaths loaded modules, so compare against the realpath.) + expect(mod.dirName).toBe(await fs.realpath(dir)); + expect(mod.html).toBe("PLAN-UI"); + }); + + it("resolves a .css sibling of a relatively-imported submodule", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "multi-file-ext", version: "1.0.0" }), + "sub/widget.css": ".x{color:red}", + "sub/widget.ts": [ + 'import { readFileSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + 'import * as path from "node:path";', + "const here = path.dirname(fileURLToPath(import.meta.url));", + 'export const css = readFileSync(path.join(here, "widget.css"), "utf8");', + ].join("\n"), + "index.ts": ['export { css } from "./sub/widget.ts";', "export default function (pi) { void pi; }"].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { css: string }; + + // The submodule under sub/ also runs in place, so its sibling asset + // resolves relative to sub/ rather than a flattened mirror root. + expect(mod.css).toBe(".x{color:red}"); + }); + + it("loads the extension's own node_modules deps natively while remapping legacy pi imports", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "dep-ext", version: "1.0.0" }), + "node_modules/cjsdep/package.json": JSON.stringify({ name: "cjsdep", version: "1.0.0", main: "index.js" }), + "node_modules/cjsdep/index.js": 'module.exports = { value: "cjs-native" };', + "index.ts": [ + 'import cjs from "cjsdep";', + // `@earendil-works/*` is a fork alias with no real published package, + // so a working import proves the load-time rewrite fired rather than + // a coincidental native resolution against a cached package. + 'import { z } from "@earendil-works/pi-ai";', + "export const depValue = cjs.value;", + 'export const hasZod = typeof z?.object === "function";', + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { depValue: string; hasZod: boolean }; + + // CJS dep under node_modules keeps Bun's native resolution (it is excluded + // from the rewrite onLoad), and the legacy pi import is remapped to the + // bundled Zod-backed shim. + expect(mod.depValue).toBe("cjs-native"); + expect(mod.hasZod).toBe(true); + }); + + it("rewrites legacy imports in ../src modules reached through relative imports", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "dist-ext", version: "1.0.0" }), + "src/helper.ts": [ + 'import { isCompiledBinary } from "@earendil-works/pi-utils";', + 'export const ok = typeof isCompiledBinary === "function";', + ].join("\n"), + "dist/extension.ts": [ + 'export { ok } from "../src/helper.ts";', + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "dist", "extension.ts"))) as { ok: boolean }; + + // `../src/helper.ts` lives outside the entry's own dir but is part of the + // entry's relative-import graph, so its legacy import is still rewritten. + expect(mod.ok).toBe(true); + }); + + it("does not rewrite sibling files outside the loaded extension's import graph", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "scoped-ext", version: "1.0.0" }), + "index.ts": ['export { local } from "./local.ts";', "export default function (pi) { void pi; }"].join("\n"), + "local.ts": 'export const local = "local-ok";', + // Not imported by index.ts, so it must stay outside the rewrite scope. + // `@earendil-works/*` only resolves via the rewrite, so an un-rewritten + // import fails — proving the hook did not over-reach to this sibling. + "unrelated.ts": [ + 'import { z } from "@earendil-works/pi-ai";', + 'export const hasZod = typeof z?.object === "function";', + ].join("\n"), + }); + + const entryMod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { local: string }; + expect(entryMod.local).toBe("local-ok"); + + // Loading the un-imported sibling directly must NOT benefit from the + // extension's rewrite hook; its fork-scope import stays unresolved. + const siblingUrl = `${url.pathToFileURL(await fs.realpath(path.join(dir, "unrelated.ts"))).href}?nonce=${Date.now()}`; + await expect(import(siblingUrl)).rejects.toThrow(/@earendil-works\/pi-ai/); + }); +}); diff --git a/packages/coding-agent/test/history-storage-session.test.ts b/packages/coding-agent/test/history-storage-session.test.ts new file mode 100644 index 000000000..2c83368d6 --- /dev/null +++ b/packages/coding-agent/test/history-storage-session.test.ts @@ -0,0 +1,145 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { HistoryStorage } from "../src/session/history-storage"; + +let tempDir = ""; + +async function freshStorage(prefix = "omp-history-session-"): Promise<{ storage: HistoryStorage; dbPath: string }> { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const dbPath = path.join(tempDir, "history.db"); + HistoryStorage.resetInstance(); + return { storage: HistoryStorage.open(dbPath), dbPath }; +} + +/** Drain the 100ms insert batch window, then await the pending writes. */ +async function flush(...writes: Promise[]): Promise { + vi.advanceTimersByTime(100); + await Promise.all(writes); +} + +beforeEach(() => { + HistoryStorage.resetInstance(); + vi.useFakeTimers(); +}); + +afterEach(async () => { + HistoryStorage.resetInstance(); + vi.useRealTimers(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } +}); + +describe("HistoryStorage session linkage", () => { + it("persists the originating session id and surfaces it on recent + search", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("deploy the service", "/repo", "session-abc")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("session-abc"); + expect(storage.search("deploy", 10)[0]?.sessionId).toBe("session-abc"); + }); + + it("falls back to the session resolver when no explicit id is passed", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => "resolved-session"); + await flush(storage.add("run the tests", "/repo")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("resolved-session"); + }); + + it("prefers an explicit session id over the resolver", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => "resolved-session"); + await flush(storage.add("explicit wins", "/repo", "explicit-session")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("explicit-session"); + }); + + it("captures the session active at add() time, not at flush time", async () => { + const { storage } = await freshStorage(); + let current = "first-session"; + storage.setSessionResolver(() => current); + // Both adds land in the same batch window; the session must be bound when + // each prompt is submitted, not when the shared batch is written. + const a = storage.add("prompt in first", "/repo"); + current = "second-session"; + const b = storage.add("prompt in second", "/repo"); + await flush(a, b); + + const byPrompt = new Map(storage.getRecent(10).map(e => [e.prompt, e.sessionId])); + expect(byPrompt.get("prompt in first")).toBe("first-session"); + expect(byPrompt.get("prompt in second")).toBe("second-session"); + }); + + it("normalizes an empty session id to null", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => ""); + await flush(storage.add("no session", "/repo")); + + expect(storage.getRecent(10)[0]?.sessionId).toBeUndefined(); + }); + + it("adds session_id to a pre-existing schema and leaves legacy rows unstamped", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-history-session-migrate-")); + const dbPath = path.join(tempDir, "history.db"); + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), + cwd TEXT + ); + `); + legacyDb.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)").run("legacy prompt", "/legacy"); + legacyDb.close(); + + HistoryStorage.resetInstance(); + const storage = HistoryStorage.open(dbPath); + await flush(storage.add("new prompt", "/new", "session-xyz")); + + const byPrompt = new Map(storage.getRecent(10).map(e => [e.prompt, e.sessionId])); + expect(byPrompt.get("legacy prompt")).toBeUndefined(); + expect(byPrompt.get("new prompt")).toBe("session-xyz"); + + const verify = new Database(dbPath, { readonly: true }); + try { + const columns = verify.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>; + expect(columns.some(col => col.name === "session_id")).toBe(true); + } finally { + verify.close(); + } + }); +}); + +describe("HistoryStorage.matchingSessionIds", () => { + it("returns matching session ids ordered by recency, de-duplicated", async () => { + const { storage } = await freshStorage(); + await flush( + storage.add("deploy alpha", "/r", "sess-1"), + storage.add("deploy beta", "/r", "sess-1"), + storage.add("deploy gamma", "/r", "sess-2"), + ); + + // Most recent matching prompt first; sess-1 appears once despite two prompts. + expect(storage.matchingSessionIds("deploy", 100)).toEqual(["sess-2", "sess-1"]); + }); + + it("skips prompts that have no recorded session", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("orphan prompt", "/r")); + + expect(storage.matchingSessionIds("orphan", 100)).toEqual([]); + }); + + it("returns no session ids when nothing matches", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("deploy alpha", "/r", "sess-1")); + + expect(storage.matchingSessionIds("nonexistent", 100)).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index 50f1b61fb..672ebcd81 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { HookSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/hook-selector"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { visibleWidth } from "@oh-my-pi/pi-tui"; beforeAll(async () => { @@ -172,4 +172,51 @@ describe("HookSelectorComponent", () => { expect(plain).toContain("Launch a browser flow"); expect(plain).not.toContain("Path A"); }); + + it("skips disabled options during keyboard navigation", () => { + let selected: string | undefined; + const component = new HookSelectorComponent( + "Pick one", + ["First", "Disabled", "Third"], + option => { + selected = option; + }, + () => {}, + { disabledIndices: [1] }, + ); + + component.handleInput("j"); + component.handleInput("\n"); + + expect(selected).toBe("Third"); + }); + + it("does not select disabled options", () => { + let selected: string | undefined; + const component = new HookSelectorComponent( + "Pick one", + ["Disabled"], + option => { + selected = option; + }, + () => {}, + { disabledIndices: [0] }, + ); + + component.handleInput("\n"); + + expect(selected).toBeUndefined(); + }); + + it("renders disabled options dimmed", () => { + const component = new HookSelectorComponent( + "Pick one", + ["First", "Disabled"], + () => {}, + () => {}, + { disabledIndices: [1] }, + ); + + expect(component.render(80).join("\n")).toContain(theme.fg("dim", "Disabled")); + }); }); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index acb9f986f..1a96d0608 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; import { Text } from "@oh-my-pi/pi-tui"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { formatNumber, TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; import type { HookSelectorSlider } from "../src/modes/components/hook-selector"; import { InteractiveMode } from "../src/modes/interactive-mode"; @@ -28,6 +28,35 @@ const isPlanApprovedCall = (args: unknown[]): boolean => args[1] !== null && (args[1] as { synthetic?: boolean }).synthetic === true; +function usageWithInput(input: number): Usage { + return { + input, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantWithUsage(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: usageWithInput(0), + stopReason: "stop", + timestamp: Date.now(), + ...overrides, + }; +} + +function compactNumber(value: number): string { + return formatNumber(value).toLowerCase(); +} + describe("InteractiveMode plan review rendering", () => { let tempDir: TempDir; let authStorage: AuthStorage; @@ -44,6 +73,7 @@ describe("InteractiveMode plan review rendering", () => { tempDir = TempDir.createSync("@pi-plan-review-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) { @@ -150,12 +180,144 @@ describe("InteractiveMode plan review rendering", () => { expect(selector).toHaveBeenCalledWith( "Plan mode - next step", - ["Approve and execute", "Approve and compact context", "Approve and keep context (73.2%)", "Refine plan"], + [ + "Approve and execute", + "Approve and compact context", + "Approve and keep context (~7.3k / 10k)", + "Refine plan", + ], expect.any(Object), expect.any(Object), ); }); + it("ignores aborted zero-usage assistant messages when estimating context usage", () => { + session.agent.appendMessage(assistantWithUsage({ usage: usageWithInput(7320), stopReason: "stop" })); + session.agent.appendMessage(assistantWithUsage({ usage: usageWithInput(0), stopReason: "aborted" })); + + expect(session.getContextUsage({ contextWindow: 10000 })).toMatchObject({ + tokens: 7320, + contextWindow: 10000, + percent: 73.2, + }); + }); + + it("measures keep-context approval against the execution model restored after plan mode", async () => { + mode.stop(); + await session.dispose(); + + const modelRegistry = new ModelRegistry(authStorage); + const executionModel = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + const planModel = modelRegistry.find("anthropic", "claude-opus-4-6"); + if (!executionModel?.contextWindow || !planModel?.contextWindow) { + throw new Error("Expected test models with context windows"); + } + session = new AgentSession({ + agent: new Agent({ + initialState: { + model: executionModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated({ modelRoles: { plan: `anthropic/${planModel.id}` } }), + modelRegistry, + }); + mode = new InteractiveMode(session, "test"); + + await mode.handlePlanModeCommand(); + expect(session.model?.id).toBe(planModel.id); + + const planFilePath = mode.planModePlanFilePath ?? "local://PLAN.md"; + const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { + getArtifactsDir: () => session.sessionManager.getArtifactsDir(), + getSessionId: () => session.sessionManager.getSessionId(), + }); + await Bun.write(resolvedPlanPath, "# Plan\n\nUse execution context."); + + const tokens = 180000; + const contextSpy = vi.spyOn(session, "getContextUsage").mockImplementation(options => { + const contextWindow = options?.contextWindow ?? 0; + return { + tokens, + contextWindow, + percent: (tokens / contextWindow) * 100, + }; + }); + const selector = vi.spyOn(mode, "showHookSelector").mockResolvedValue("Refine plan"); + + await mode.handlePlanApproval({ + planFilePath, + planExists: true, + title: "PLAN", + finalPlanFilePath: "local://APPROVED.md", + }); + + expect(contextSpy).toHaveBeenCalledWith({ contextWindow: executionModel.contextWindow }); + expect(selector.mock.calls[0]?.[1]).toEqual([ + "Approve and execute", + "Approve and compact context", + `Approve and keep context (~${compactNumber(tokens)} / ${compactNumber(executionModel.contextWindow)})`, + "Refine plan", + ]); + }); + + it("disables keep-context approval when execution context usage is above ninety-five percent", async () => { + const planFilePath = "local://PLAN.md"; + const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { + getArtifactsDir: () => session.sessionManager.getArtifactsDir(), + getSessionId: () => session.sessionManager.getSessionId(), + }); + await Bun.write(resolvedPlanPath, "# Plan\n\nToo much context."); + + mode.planModeEnabled = true; + mode.planModePlanFilePath = planFilePath; + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 9600, contextWindow: 10000, percent: 96 }); + const selector = vi.spyOn(mode, "showHookSelector").mockResolvedValue("Refine plan"); + + await mode.handlePlanApproval({ + planFilePath, + planExists: true, + title: "PLAN", + finalPlanFilePath: "local://APPROVED.md", + }); + + expect(selector.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + disabledIndices: [2], + }), + ); + }); + + it("keeps keep-context approval enabled at exactly ninety-five percent", async () => { + const planFilePath = "local://PLAN.md"; + const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { + getArtifactsDir: () => session.sessionManager.getArtifactsDir(), + getSessionId: () => session.sessionManager.getSessionId(), + }); + await Bun.write(resolvedPlanPath, "# Plan\n\nAt the threshold."); + + mode.planModeEnabled = true; + mode.planModePlanFilePath = planFilePath; + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 9500, contextWindow: 10000, percent: 95 }); + const selector = vi.spyOn(mode, "showHookSelector").mockResolvedValue("Refine plan"); + + await mode.handlePlanApproval({ + planFilePath, + planExists: true, + title: "PLAN", + finalPlanFilePath: "local://APPROVED.md", + }); + + expect(selector.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + disabledIndices: undefined, + }), + ); + }); + it("keeps the keep-context label plain when context usage is unknown", async () => { const planFilePath = "local://PLAN.md"; const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { diff --git a/packages/coding-agent/test/interactive-mode-shutdown.test.ts b/packages/coding-agent/test/interactive-mode-shutdown.test.ts new file mode 100644 index 000000000..3d010d68d --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-shutdown.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { postmortem, TempDir } from "@oh-my-pi/pi-utils"; + +describe("InteractiveMode shutdown", () => { + let authStorage: AuthStorage; + let mode: InteractiveMode; + let session: AgentSession; + let tempDir: TempDir; + + beforeAll(() => { + initTheme(); + }); + + beforeEach(async () => { + resetSettingsForTest(); + tempDir = TempDir.createSync("@pi-shutdown-"); + await Settings.init({ inMemory: true, cwd: tempDir.path() }); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 test model"); + + session = new AgentSession({ + agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated(), + modelRegistry, + }); + mode = new InteractiveMode(session, "test"); + }); + + afterEach(async () => { + mode?.stop(); + vi.restoreAllMocks(); + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + resetSettingsForTest(); + }); + + it("stops from the last committed TUI frame without forcing a teardown repaint", async () => { + const requestRenderSpy = vi.spyOn(mode.ui, "requestRender").mockImplementation(() => {}); + const stopSpy = vi.spyOn(mode.ui, "stop").mockImplementation(() => {}); + const drainSpy = vi.spyOn(mode.ui.terminal, "drainInput").mockResolvedValue(undefined); + const disposeSpy = vi.spyOn(session, "dispose").mockResolvedValue(undefined); + const quitSpy = vi.spyOn(postmortem, "quit").mockResolvedValue(undefined); + vi.spyOn(session.sessionManager, "getSessionId").mockReturnValue(""); + mode.isInitialized = true; + + await mode.shutdown(); + + expect(disposeSpy).toHaveBeenCalled(); + expect(requestRenderSpy.mock.calls.some(call => call[0] === true)).toBe(false); + expect(drainSpy).toHaveBeenCalledWith(1000); + expect(stopSpy).toHaveBeenCalled(); + expect(quitSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts new file mode 100644 index 000000000..624084897 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { ModelRegistry } from "../src/config/model-registry"; +import { InteractiveMode } from "../src/modes/interactive-mode"; +import { AgentSession } from "../src/session/agent-session"; +import { AuthStorage } from "../src/session/auth-storage"; +import { SessionManager } from "../src/session/session-manager"; +import type { TodoPhase } from "../src/tools/todo-write"; + +function renderTodos(mode: InteractiveMode): string { + return Bun.stripANSI(mode.todoContainer.render(120).join("\n")); +} + +describe("InteractiveMode todo auto-clear", () => { + let tempDir: TempDir; + let authStorage: AuthStorage; + let session: AgentSession; + let mode: InteractiveMode; + + beforeAll(async () => { + await initTheme(); + }); + + beforeEach(async () => { + resetSettingsForTest(); + tempDir = TempDir.createSync("@pi-todo-clear-"); + }); + + afterEach(async () => { + mode?.stop(); + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + vi.useRealTimers(); + vi.restoreAllMocks(); + resetSettingsForTest(); + }); + + async function createMode(todoClearDelay: number): Promise { + await Settings.init({ + inMemory: true, + cwd: tempDir.path(), + overrides: { "tasks.todoClearDelay": todoClearDelay }, + }); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); + + session = new AgentSession({ + agent: new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated({ "tasks.todoClearDelay": todoClearDelay }), + modelRegistry, + }); + mode = new InteractiveMode(session, "test"); + } + + it("clears closed todos from the panel instantly without mutating session history", async () => { + await createMode(0); + const phases: TodoPhase[] = [ + { + name: "Implementation", + tasks: [ + { content: "done task", status: "completed" }, + { content: "abandoned task", status: "abandoned" }, + ], + }, + ]; + session.setTodoPhases(phases); + + mode.setTodos(session.getTodoPhases()); + + expect(renderTodos(mode)).not.toContain("done task"); + expect(renderTodos(mode)).not.toContain("abandoned task"); + expect(session.getTodoPhases()).toEqual(phases); + }); + + it("leaves closed todos visible when auto-clear is disabled", async () => { + await createMode(-1); + + mode.setTodos([{ name: "Implementation", tasks: [{ content: "done task", status: "completed" }] }]); + + expect(renderTodos(mode)).toContain("done task"); + }); + + it("clears closed todos after the configured delay", async () => { + await createMode(1); + vi.useFakeTimers(); + + mode.setTodos([{ name: "Implementation", tasks: [{ content: "done task", status: "completed" }] }]); + expect(renderTodos(mode)).toContain("done task"); + + vi.advanceTimersByTime(999); + expect(renderTodos(mode)).toContain("done task"); + + vi.advanceTimersByTime(1); + expect(renderTodos(mode)).not.toContain("done task"); + }); +}); diff --git a/packages/coding-agent/test/keybindings-selector-navigation.test.ts b/packages/coding-agent/test/keybindings-selector-navigation.test.ts index 199979220..d7bc3ffdb 100644 --- a/packages/coding-agent/test/keybindings-selector-navigation.test.ts +++ b/packages/coding-agent/test/keybindings-selector-navigation.test.ts @@ -98,7 +98,7 @@ describe("selector navigation keybindings", () => { const selected: string[] = []; const selector = new SessionSelectorComponent( [createSession("session-a", "Alpha"), createSession("session-b", "Beta")], - path => selected.push(path), + session => selected.push(session.path), () => {}, () => {}, ); diff --git a/packages/coding-agent/test/main-cross-project-resume.test.ts b/packages/coding-agent/test/main-cross-project-resume.test.ts new file mode 100644 index 000000000..02348fc4d --- /dev/null +++ b/packages/coding-agent/test/main-cross-project-resume.test.ts @@ -0,0 +1,75 @@ +/** + * Regression: declining the cross-project fork prompt during `--resume ` + * must exit cleanly, while non-interactive resume still fails instead of + * silently succeeding. See #1668. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { Args } from "@oh-my-pi/pi-coding-agent/cli/args"; +import type { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createSessionManager } from "@oh-my-pi/pi-coding-agent/main"; +import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import * as sessionManagerModule from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +function buildArgs(resume: string): Args { + return { + resume, + messages: [], + fileArgs: [], + unknownFlags: new Map(), + }; +} + +function buildGlobalMatch(cwd: string): { session: SessionInfo; scope: "global" } { + return { + scope: "global", + session: { + path: `${cwd}/019e84ed-b4cc-7000-9c87-5afe6df992c1.jsonl`, + id: "019e84ed-b4cc-7000-9c87-5afe6df992c1", + cwd, + title: "in-other-project", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + size: 0, + firstMessage: "", + allMessagesText: "", + }, + }; +} + +describe("createSessionManager — cross-project --resume cancellation (#1668)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns undefined when an interactive user declines the fork prompt instead of throwing", async () => { + const sessionCwd = "/some/other/project"; + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd)); + + const args = buildArgs("019e84ed"); + const stubSettings = { get: () => undefined } as unknown as Settings; + + const result = await createSessionManager( + args, + "/current/project", + stubSettings, + async () => "declined" as const, + ); + + expect(result).toBeUndefined(); + }); + + it("throws when the cross-project fork prompt is unavailable in non-interactive mode", async () => { + expect(process.stdin.isTTY).toBeFalsy(); + + const sessionCwd = "/some/other/project"; + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd)); + + const args = buildArgs("019e84ed"); + const stubSettings = { get: () => undefined } as unknown as Settings; + + await expect(createSessionManager(args, "/current/project", stubSettings)).rejects.toThrow( + 'Session "019e84ed" is in another project (/some/other/project); run interactively to fork it into the current project.', + ); + }); +}); diff --git a/packages/coding-agent/test/modes/components/oauth-selector.test.ts b/packages/coding-agent/test/modes/components/oauth-selector.test.ts index eb72f62bc..8267c5e09 100644 --- a/packages/coding-agent/test/modes/components/oauth-selector.test.ts +++ b/packages/coding-agent/test/modes/components/oauth-selector.test.ts @@ -9,6 +9,7 @@ beforeAll(async () => { }); const authStorage = { + has: (_providerId: string) => false, hasAuth: (_providerId: string) => false, } as unknown as AuthStorage; @@ -45,4 +46,57 @@ describe("OAuthSelectorComponent", () => { component.handleInput("\n"); expect(selected).toEqual([target.id]); }); + + it("does not offer env-only providers as logout targets", () => { + const selected: string[] = []; + const component = new OAuthSelectorComponent( + "logout", + { + has: (_providerId: string) => false, + hasAuth: (providerId: string) => providerId === "opencode-go" || providerId === "opencode-zen", + } as unknown as AuthStorage, + providerId => selected.push(providerId), + () => {}, + ); + + for (const char of "opencode-go") { + component.handleInput(char); + } + + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain("No stored provider credentials to log out"); + + component.handleInput("\n"); + expect(selected).toEqual([]); + }); + + it("offers stored providers as logout targets", () => { + const selected: string[] = []; + const component = new OAuthSelectorComponent( + "logout", + { + has: (providerId: string) => providerId === "opencode-go", + hasAuth: (providerId: string) => providerId === "opencode-go", + } as unknown as AuthStorage, + providerId => selected.push(providerId), + () => {}, + ); + + for (const char of "opencode-go") { + component.handleInput(char); + } + + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain("OpenCode Go"); + expect(rendered).toContain("logged in"); + + component.handleInput("\n"); + expect(selected).toEqual(["opencode-go"]); + }); }); diff --git a/packages/coding-agent/test/modes/components/session-selector-scope.test.ts b/packages/coding-agent/test/modes/components/session-selector-scope.test.ts new file mode 100644 index 000000000..a7a999dbd --- /dev/null +++ b/packages/coding-agent/test/modes/components/session-selector-scope.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { SessionSelectorComponent } from "../../../src/modes/components/session-selector"; +import { initTheme } from "../../../src/modes/theme/theme"; +import type { SessionInfo } from "../../../src/session/session-manager"; + +beforeAll(() => { + initTheme(); +}); + +function createSession(id: string, title: string, cwd: string): SessionInfo { + return { + path: `${cwd}/${id}.jsonl`, + id, + cwd, + title, + created: new Date("2024-01-01T00:00:00Z"), + modified: new Date("2024-01-02T00:00:00Z"), + messageCount: 1, + size: 0, + firstMessage: `${title} first message`, + allMessagesText: `${title} first message`, + }; +} + +const TAB = "\t"; + +describe("SessionSelectorComponent scope toggle", () => { + it("loads the all-projects list on Tab and surfaces each session's directory", async () => { + const folder = [createSession("local", "Local", "/work/current")]; + const global = [ + createSession("local", "Local", "/work/current"), + createSession("remote", "Remote", "/work/other-project"), + ]; + let loads = 0; + const selector = new SessionSelectorComponent( + folder, + () => {}, + () => {}, + () => {}, + { + loadAllSessions: async () => { + loads++; + return global; + }, + }, + ); + + // Folder scope: header says current folder, no foreign cwd column. + expect(selector.render(120).join("\n")).toContain("(current folder)"); + expect(selector.render(120).join("\n")).not.toContain("other-project"); + + selector.handleInput(TAB); + await Bun.sleep(0); + + const rendered = selector.render(120).join("\n"); + expect(rendered).toContain("(all projects)"); + expect(rendered).toContain("other-project"); + expect(loads).toBe(1); + + // Toggling back returns to folder scope without reloading. + selector.handleInput(TAB); + await Bun.sleep(0); + expect(selector.render(120).join("\n")).toContain("(current folder)"); + expect(loads).toBe(1); + + // Re-entering all scope reuses the cached global list. + selector.handleInput(TAB); + await Bun.sleep(0); + expect(loads).toBe(1); + }); + + it("returns the full selected session, including its cwd", async () => { + const folder = [createSession("local", "Local", "/work/current")]; + const remote = createSession("remote", "Remote", "/work/other-project"); + const selected: SessionInfo[] = []; + const selector = new SessionSelectorComponent( + folder, + session => selected.push(session), + () => {}, + () => {}, + { loadAllSessions: async () => [remote] }, + ); + + selector.handleInput(TAB); + await Bun.sleep(0); + selector.handleInput("\n"); + + expect(selected).toHaveLength(1); + expect(selected[0]?.path).toBe(remote.path); + expect(selected[0]?.cwd).toBe("/work/other-project"); + }); + + it("opens directly in all-projects scope when started there with a preloaded list", () => { + const global = [createSession("remote", "Remote", "/work/other-project")]; + const selector = new SessionSelectorComponent( + [], + () => {}, + () => {}, + () => {}, + { allSessions: global, startInAllScope: true, loadAllSessions: async () => global }, + ); + + const rendered = selector.render(120).join("\n"); + expect(rendered).toContain("(all projects)"); + expect(rendered).toContain("other-project"); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts index ef39e7588..ea938f3f4 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts @@ -148,6 +148,7 @@ beforeAll(() => { describe("SelectorController session deletion", () => { beforeEach(() => { vi.spyOn(SessionManager, "list").mockResolvedValue([]); + vi.spyOn(SessionManager, "listAll").mockResolvedValue([]); }); afterEach(() => { diff --git a/packages/coding-agent/test/modes/controllers/session-selector-delete.test.ts b/packages/coding-agent/test/modes/controllers/session-selector-delete.test.ts index 770e048e8..79a322265 100644 --- a/packages/coding-agent/test/modes/controllers/session-selector-delete.test.ts +++ b/packages/coding-agent/test/modes/controllers/session-selector-delete.test.ts @@ -32,7 +32,7 @@ function createSelector(onDelete: (session: SessionInfo) => Promise): S () => {}, () => {}, () => {}, - onDelete, + { onDelete }, ); } diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index ee383200b..c702edf29 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -149,4 +149,140 @@ describe("createAgentSession deferred model pattern resolution", () => { authStorage.close(); } }); + + test("restores role model from extension provider after startup resume", async () => { + const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!defaultModel) { + throw new Error("Expected bundled anthropic default model"); + } + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey(defaultModel.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + + const targetSessionFile = path.join(tempDir, "resume-extension.jsonl"); + const timestamp = "2026-06-01T00:00:00.000Z"; + await Bun.write( + targetSessionFile, + `${[ + { type: "session", version: 3, id: "resume-ext", timestamp, cwd: tempDir }, + { + type: "model_change", + id: "default-model", + parentId: null, + timestamp, + model: `${defaultModel.provider}/${defaultModel.id}`, + role: "default", + }, + { + type: "model_change", + id: "smol-model", + parentId: "default-model", + timestamp, + model: "runtime-provider/runtime-model", + role: "smol", + }, + ] + .map(entry => JSON.stringify(entry)) + .join("\n")}\n`, + ); + const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "sessions")); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + modelRegistry, + sessionManager, + settings: Settings.isolated(), + disableExtensionDiscovery: true, + extensions: [providerExtension], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + }); + + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + } finally { + await session.dispose(); + authStorage.close(); + } + }); + + test("restores extension role model when saved default cannot be restored before extensions load", async () => { + const settingsDefaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!settingsDefaultModel) { + throw new Error("Expected bundled anthropic default model"); + } + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey(settingsDefaultModel.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + + // Saved default points at a provider that has no usable credentials. The + // last active role (`smol`) is supplied by the inline extension and is + // only resolvable once provider registrations are processed. + const targetSessionFile = path.join(tempDir, "resume-extension-default-missing.jsonl"); + const timestamp = "2026-06-01T00:00:00.000Z"; + await Bun.write( + targetSessionFile, + `${[ + { type: "session", version: 3, id: "resume-ext-no-default", timestamp, cwd: tempDir }, + { + type: "model_change", + id: "default-model", + parentId: null, + timestamp, + model: "anthropic/not-available", + role: "default", + }, + { + type: "model_change", + id: "smol-model", + parentId: "default-model", + timestamp, + model: "runtime-provider/runtime-model", + role: "smol", + }, + ] + .map(entry => JSON.stringify(entry)) + .join("\n")}\n`, + ); + const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "sessions-no-default")); + + const settings = Settings.isolated(); + settings.setModelRole("default", `${settingsDefaultModel.provider}/${settingsDefaultModel.id}`); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + modelRegistry, + sessionManager, + settings, + disableExtensionDiscovery: true, + extensions: [providerExtension], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + }); + + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + } finally { + await session.dispose(); + authStorage.close(); + } + }); }); diff --git a/packages/coding-agent/test/session-manager-cwd-adoption.test.ts b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts new file mode 100644 index 000000000..06f3c3a37 --- /dev/null +++ b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const tempDirs: TempDir[] = []; + +function makeTempDir(prefix: string): string { + const dir = TempDir.createSync(prefix); + tempDirs.push(dir); + return dir.path(); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => dir.remove())); +}); + +/** + * Persist a single-message session under `cwd`/`sessionDir` and return its file path. + * The on-disk header records `cwd`, which is what resume adoption keys off of. + */ +async function writeSession(cwd: string, sessionDir: string): Promise { + const manager = SessionManager.create(cwd, sessionDir); + manager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() }); + await manager.rewriteEntries(); + const file = manager.getSessionFile(); + if (!file) throw new Error("expected a persisted session file"); + return file; +} + +describe("SessionManager cwd adoption on resume", () => { + it("adopts the resumed session's own cwd and session directory", async () => { + const projectA = makeTempDir("@pi-cwd-a-"); + const projectB = makeTempDir("@pi-cwd-b-"); + const sessionsB = path.join(projectB, "sessions"); + const fileB = await writeSession(projectB, sessionsB); + + // A manager started in project A loads a session that lives in project B. + const manager = SessionManager.create(projectA, path.join(projectA, "sessions")); + expect(manager.getCwd()).toBe(path.resolve(projectA)); + + await manager.setSessionFile(fileB); + + expect(manager.getCwd()).toBe(path.resolve(projectB)); + expect(manager.getSessionDir()).toBe(path.resolve(sessionsB)); + // New session/fork targets must follow the adopted directory, not the launch one. + expect(manager.getHeader()?.cwd).toBe(path.resolve(projectB)); + }); + + it("leaves cwd untouched when the resumed session has no recorded cwd", async () => { + const projectA = makeTempDir("@pi-cwd-a-"); + const projectB = makeTempDir("@pi-cwd-b-"); + const sessionsB = path.join(projectB, "sessions"); + const fileB = await writeSession(projectB, sessionsB); + + // Simulate a legacy session whose header predates the cwd field. + const raw = await Bun.file(fileB).text(); + const lines = raw.split("\n").filter(Boolean); + const header = JSON.parse(lines[0]) as Record; + header.cwd = ""; + lines[0] = JSON.stringify(header); + await Bun.write(fileB, `${lines.join("\n")}\n`); + + const launchDir = path.join(projectA, "sessions"); + const manager = SessionManager.create(projectA, launchDir); + await manager.setSessionFile(fileB); + + expect(manager.getCwd()).toBe(path.resolve(projectA)); + expect(manager.getSessionDir()).toBe(path.resolve(launchDir)); + }); + + it("restores cwd and session directory when a switch is rolled back", async () => { + const projectA = makeTempDir("@pi-cwd-a-"); + const projectB = makeTempDir("@pi-cwd-b-"); + const sessionsA = path.join(projectA, "sessions"); + const sessionsB = path.join(projectB, "sessions"); + const fileB = await writeSession(projectB, sessionsB); + + const manager = SessionManager.create(projectA, sessionsA); + const snapshot = manager.captureState(); + + await manager.setSessionFile(fileB); + expect(manager.getCwd()).toBe(path.resolve(projectB)); + + manager.restoreState(snapshot); + expect(manager.getCwd()).toBe(path.resolve(projectA)); + expect(manager.getSessionDir()).toBe(path.resolve(sessionsA)); + }); +}); diff --git a/packages/coding-agent/test/session-ranking.test.ts b/packages/coding-agent/test/session-ranking.test.ts new file mode 100644 index 000000000..109162260 --- /dev/null +++ b/packages/coding-agent/test/session-ranking.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { mergeSessionRanking } from "../src/modes/components/session-selector"; +import type { SessionInfo } from "../src/session/session-manager"; + +function makeSession(id: string): SessionInfo { + return { + path: `${id}.jsonl`, + id, + cwd: "/repo", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + size: 100, + firstMessage: "", + allMessagesText: "", + }; +} + +const ids = (sessions: SessionInfo[]): string[] => sessions.map(s => s.id); + +describe("mergeSessionRanking", () => { + it("orders dual matches first (in fuzzy order), then fuzzy-only, then history-only", () => { + const all = ["a", "b", "c", "d", "e"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = ["a", "b", "c"].map(id => byId.get(id)!); // metadata matches, best→worst + const historyIds = ["c", "a", "e"]; // prompt matches, best→worst + + // a,c matched both → lead in their fuzzy order [a, c]; b fuzzy-only; e history-only. + expect(ids(mergeSessionRanking(all, fuzzy, historyIds))).toEqual(["a", "c", "b", "e"]); + }); + + it("never drops a fuzzy match and appends history-only matches after it", () => { + const all = ["a", "b"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = [byId.get("a")!]; + + expect(ids(mergeSessionRanking(all, fuzzy, ["b"]))).toEqual(["a", "b"]); + }); + + it("surfaces purely history-matched sessions ordered by history relevance", () => { + const all = ["a", "b", "c"].map(makeSession); + + // No fuzzy match at all; c is the most relevant prompt match, then a. b is excluded. + expect(ids(mergeSessionRanking(all, [], ["c", "a"]))).toEqual(["c", "a"]); + }); + + it("ignores history matches for sessions absent from the list", () => { + const all = [makeSession("a")]; + const byId = new Map(all.map(s => [s.id, s])); + + // "z" is matched in history but not resumable from this list → dropped. + expect(ids(mergeSessionRanking(all, [byId.get("a")!], ["a", "z"]))).toEqual(["a"]); + }); + + it("returns the fuzzy result unchanged when there are no history matches", () => { + const all = ["a", "b"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = ["b", "a"].map(id => byId.get(id)!); + + expect(ids(mergeSessionRanking(all, fuzzy, []))).toEqual(["b", "a"]); + }); +}); diff --git a/packages/coding-agent/test/settings-reload-cwd.test.ts b/packages/coding-agent/test/settings-reload-cwd.test.ts new file mode 100644 index 000000000..13760417e --- /dev/null +++ b/packages/coding-agent/test/settings-reload-cwd.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; + +describe("Settings.reloadForCwd", () => { + it("re-resolves path-scoped settings against the new directory in place", async () => { + const projectA = path.resolve("/tmp", `reload-a-${Snowflake.next()}`); + const projectB = path.resolve("/tmp", `reload-b-${Snowflake.next()}`); + const settings = Settings.isolated({ + enabledModels: [ + { paths: [projectA], models: ["model-a"] }, + { paths: [projectB], models: ["model-b"] }, + ], + // A plain (non-scoped) override must survive re-scoping. + "compaction.enabled": false, + }); + + await settings.reloadForCwd(projectA); + expect(settings.getCwd()).toBe(path.normalize(projectA)); + expect(settings.get("enabledModels")).toEqual(["model-a"]); + expect(settings.get("compaction.enabled")).toBe(false); + + await settings.reloadForCwd(projectB); + expect(settings.getCwd()).toBe(path.normalize(projectB)); + expect(settings.get("enabledModels")).toEqual(["model-b"]); + // Non-scoped override is preserved across the switch. + expect(settings.get("compaction.enabled")).toBe(false); + }); + + it("is a no-op when the target directory is already the active scope", async () => { + const projectA = path.resolve("/tmp", `reload-noop-${Snowflake.next()}`); + const settings = Settings.isolated({ + enabledModels: [{ paths: [projectA], models: ["model-a"] }], + }); + + await settings.reloadForCwd(projectA); + expect(settings.get("enabledModels")).toEqual(["model-a"]); + await settings.reloadForCwd(projectA); + expect(settings.getCwd()).toBe(path.normalize(projectA)); + expect(settings.get("enabledModels")).toEqual(["model-a"]); + }); + + describe("project layer (on disk)", () => { + let testDir: string; + let agentDir: string; + let startDir: string; + let scopedProject: string; + let bareProject: string; + + beforeEach(() => { + resetSettingsForTest(); + testDir = path.join(os.tmpdir(), "test-reload-cwd", Snowflake.next()); + agentDir = path.join(testDir, "agent"); + startDir = path.join(testDir, "start"); + scopedProject = path.join(testDir, "scoped"); + bareProject = path.join(testDir, "bare"); + fs.mkdirSync(agentDir, { recursive: true }); + fs.mkdirSync(startDir, { recursive: true }); + fs.mkdirSync(bareProject, { recursive: true }); + // Only the scoped project ships a project-level settings file. + fs.mkdirSync(getProjectAgentDir(scopedProject), { recursive: true }); + fs.writeFileSync( + path.join(getProjectAgentDir(scopedProject), "settings.json"), + JSON.stringify({ compaction: { enabled: false } }), + ); + }); + + afterEach(() => { + resetSettingsForTest(); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }); + + it("loads and drops project settings as the working directory changes", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + // No project file under startDir → schema default. + expect(settings.get("compaction.enabled")).toBe(true); + + await settings.reloadForCwd(scopedProject); + expect(settings.getCwd()).toBe(path.normalize(scopedProject)); + expect(settings.get("compaction.enabled")).toBe(false); + + // Moving to a project without settings drops the previous project's config. + await settings.reloadForCwd(bareProject); + expect(settings.getCwd()).toBe(path.normalize(bareProject)); + expect(settings.get("compaction.enabled")).toBe(true); + }); + }); +}); diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 76b7248e7..8d07c789d 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -209,6 +209,7 @@ function createStreamForDiff( let tempDir: string; const editTool = buildEditTool(); const seeds = [7, 21, 42, 84, 128]; +const STREAMING_EDIT_RANDOM_STREAM_TIMEOUT_MS = 20_000; beforeEach(() => { tempDir = path.join(os.tmpdir(), `pi-streaming-edit-${Snowflake.next()}`); @@ -221,57 +222,65 @@ afterEach(async () => { } }); -it("does not abort for successful patches across random streams", async () => { - await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); - const diff = "@@\n-beta\n+beta2\n"; +it( + "does not abort for successful patches across random streams", + async () => { + await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + const diff = "@@\n-beta\n+beta2\n"; - for (const seed of seeds) { - const chunks = chunkStringRandomly(diff, seed); - const abortSignalRef: { current?: AbortSignal } = {}; - const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); - const { session, authStorage } = await createSession(tempDir, streamFn, editTool); + for (const seed of seeds) { + const chunks = chunkStringRandomly(diff, seed); + const abortSignalRef: { current?: AbortSignal } = {}; + const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); + const { session, authStorage } = await createSession(tempDir, streamFn, editTool); - try { - await session.prompt("apply patch"); - - const lastAssistant = lastAssistantMessage(session.state.messages); - expect(lastAssistant?.stopReason).not.toBe("aborted"); - expect(abortSignalRef.current?.aborted ?? false).toBe(false); - } finally { try { - await session.dispose(); + await session.prompt("apply patch"); + + const lastAssistant = lastAssistantMessage(session.state.messages); + expect(lastAssistant?.stopReason).not.toBe("aborted"); + expect(abortSignalRef.current?.aborted ?? false).toBe(false); } finally { - authStorage.close(); + try { + await session.dispose(); + } finally { + authStorage.close(); + } } } - } -}); + }, + STREAMING_EDIT_RANDOM_STREAM_TIMEOUT_MS, +); -it("aborts for failing patches across random streams", async () => { - await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); - const diff = "@@\n-omega\n+beta2\n"; +it( + "aborts for failing patches across random streams", + async () => { + await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + const diff = "@@\n-omega\n+beta2\n"; - for (const seed of seeds) { - const chunks = chunkStringRandomly(diff, seed); - const abortSignalRef: { current?: AbortSignal } = {}; - const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); - const { session, authStorage } = await createSession(tempDir, streamFn, editTool); + for (const seed of seeds) { + const chunks = chunkStringRandomly(diff, seed); + const abortSignalRef: { current?: AbortSignal } = {}; + const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); + const { session, authStorage } = await createSession(tempDir, streamFn, editTool); - try { - await session.prompt("apply patch"); - - const lastAssistant = lastAssistantMessage(session.state.messages); - expect(lastAssistant?.stopReason).toBe("aborted"); - expect(abortSignalRef.current?.aborted ?? false).toBe(true); - } finally { try { - await session.dispose(); + await session.prompt("apply patch"); + + const lastAssistant = lastAssistantMessage(session.state.messages); + expect(lastAssistant?.stopReason).toBe("aborted"); + expect(abortSignalRef.current?.aborted ?? false).toBe(true); } finally { - authStorage.close(); + try { + await session.dispose(); + } finally { + authStorage.close(); + } } } - } -}); + }, + STREAMING_EDIT_RANDOM_STREAM_TIMEOUT_MS, +); it("does not abort when auto-generated peek fails with ENOENT (non-ToolError)", async () => { const checkSpy = vi diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index a9502d5be..ee5e20dca 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -108,14 +108,7 @@ describe("streaming edit preview height (stable, full tail window)", () => { const bigLines = bigNew.split("\n"); const bigPartials = bigLines.map((_v, i) => bigLines.slice(0, i + 1).join("\n")); - let resolveRender: (() => void) | null = null; - const uiStub = { - requestRender() { - const r = resolveRender; - resolveRender = null; - r?.(); - }, - } as unknown as TUI; + const uiStub = { requestRender() {} } as unknown as TUI; const tool = { mode: "replace" } as unknown as AgentTool; const component = new ToolExecutionComponent( "edit", @@ -125,9 +118,13 @@ describe("streaming edit preview height (stable, full tail window)", () => { uiStub, tmpDir, ); - const settle = () => - Promise.race([new Promise(res => (resolveRender = res)), Bun.sleep(250).then(() => undefined)]); - await settle(); + // Await the actual diff recompute rather than racing the spinner's render + // ticks. The streaming spinner calls requestRender every ~16ms, so on a + // slow box a tick — not the (file-read + whole-file Myers) compute — would + // resolve the wait and let us sample a stale, mid-abort preview. That is the + // CI flake that collapsed Math.min(...steady) to 4. whenPreviewSettled() + // resolves only when this chunk's recompute has updated the preview. + await component.whenPreviewSettled(); const trailingBlankRows = (rows: string[]): number => { let n = 0; @@ -141,14 +138,19 @@ describe("streaming edit preview height (stable, full tail window)", () => { const heights: number[] = []; let maxTrailingBlank = 0; for (const newText of bigPartials) { - const next = settle(); component.updateArgs({ path: bigFile, edits: [{ old_text: bigOld, new_text: newText }] }); - await next; + await component.whenPreviewSettled(); const rows = component.render(RENDER_WIDTH_WIDE); heights.push(rows.length); maxTrailingBlank = Math.max(maxTrailingBlank, trailingBlankRows(rows)); } + // Finalize still renders a real diff. + component.setArgsComplete(); + await component.whenPreviewSettled(); + const finalizedHeight = component.render(RENDER_WIDTH_WIDE).length; + component.stopAnimation(); + // The tail window saturates immediately and the box height holds dead // steady for the rest of the stream — it neither stutters larger/smaller // (the pre-fix overshoot) nor balloons to a high-water peak. Only the very @@ -160,11 +162,7 @@ describe("streaming edit preview height (stable, full tail window)", () => { expect(Math.max(...steady) - Math.min(...steady)).toBe(0); // And it is never padded into a half-empty rectangle (the regression). expect(maxTrailingBlank).toBeLessThanOrEqual(1); - - // Finalize still renders a real diff. - component.setArgsComplete(); - await settle(); - expect(component.render(RENDER_WIDTH_WIDE).length).toBeGreaterThan(1); + expect(finalizedHeight).toBeGreaterThan(1); }); test("real TUI finalization replaces streaming edit preview throughout native scrollback", async () => { diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index f19497abd..92fdd84d2 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -2,9 +2,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; -import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { buildSystemPrompt, loadProjectContextFiles, @@ -37,28 +34,25 @@ describe("SYSTEM.md prompt assembly", () => { fs.mkdirSync(systemDir, { recursive: true }); fs.writeFileSync(path.join(systemDir, "SYSTEM.md"), systemPrompt); - const { session } = await createAgentSession({ + const { systemPrompt: renderedPrompt } = await buildSystemPrompt({ cwd: projectDir, - agentDir: projectDir, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated(), - systemPrompt: [systemPrompt], - disableExtensionDiscovery: true, - skills: [], + customPrompt: systemPrompt, contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, + skills: [], + rules: [], + toolNames: [], + workspaceTree: { + rootPath: projectDir, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, }); - try { - const formatted = session.formatSessionAsText(); - const matches = formatted.match(new RegExp(escapeRegExp(systemPrompt), "g")) ?? []; - expect(matches).toHaveLength(1); - } finally { - await session.dispose(); - } + const promptText = renderedPrompt.join("\n\n"); + const matches = promptText.match(new RegExp(escapeRegExp(systemPrompt), "g")) ?? []; + expect(matches).toHaveLength(1); }); it("prefers project SYSTEM.md over user SYSTEM.md", async () => { diff --git a/packages/coding-agent/test/task/output-manager.test.ts b/packages/coding-agent/test/task/output-manager.test.ts new file mode 100644 index 000000000..50fef2440 --- /dev/null +++ b/packages/coding-agent/test/task/output-manager.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { AgentOutputManager } from "@oh-my-pi/pi-coding-agent/task/output-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +// Contract: subagent output ids are the requested name, used verbatim the first +// time and suffixed (`-2`, `-3`, …) only when the same name recurs. A parent +// prefix nests ids under it. On resume the manager scans existing `.md` outputs +// so it never reuses a name that would clobber a previously written output. + +describe("AgentOutputManager", () => { + it("uses the requested name verbatim and suffixes only on repeat", async () => { + const mgr = new AgentOutputManager(() => null); + + expect(await mgr.allocate("Anna")).toBe("Anna"); + expect(await mgr.allocate("Anna")).toBe("Anna-2"); + expect(await mgr.allocate("Anna")).toBe("Anna-3"); + // A distinct name is untouched — no prefix, no suffix. + expect(await mgr.allocate("Bob")).toBe("Bob"); + }); + + it("de-duplicates within a batch while preserving order", async () => { + const mgr = new AgentOutputManager(() => null); + + expect(await mgr.allocateBatch(["Auth", "Auth", "Api", "Auth"])).toEqual(["Auth", "Auth-2", "Api", "Auth-3"]); + }); + + it("nests ids under a parent prefix and still suffixes repeats", async () => { + const mgr = new AgentOutputManager(() => null, { parentPrefix: "Anna" }); + + expect(await mgr.allocate("Bob")).toBe("Anna.Bob"); + expect(await mgr.allocate("Bob")).toBe("Anna.Bob-2"); + expect(await mgr.allocate("Carol")).toBe("Anna.Carol"); + }); + + it("scans existing output files so a resume never clobbers prior outputs", async () => { + using tmp = TempDir.createSync("@omp-output-manager-"); + const dir = tmp.path(); + await Bun.write(path.join(dir, "Anna.md"), "prior"); + await Bun.write(path.join(dir, "Anna-2.md"), "prior"); + // Unrelated tool artifacts (numeric `.log` ids) must not be mistaken for names. + await Bun.write(path.join(dir, "7.bash.log"), "noise"); + + const mgr = new AgentOutputManager(() => dir); + + expect(await mgr.allocate("Anna")).toBe("Anna-3"); + // A name with no file on disk is still pristine. + expect(await mgr.allocate("Bob")).toBe("Bob"); + }); + + it("only counts files within its own prefix scope on resume", async () => { + using tmp = TempDir.createSync("@omp-output-manager-"); + const dir = tmp.path(); + await Bun.write(path.join(dir, "Anna.Bob.md"), "child"); + await Bun.write(path.join(dir, "Anna.Bob.Carol.md"), "grandchild"); + // A different parent's child must be ignored by Anna's manager. + await Bun.write(path.join(dir, "Other.Bob.md"), "elsewhere"); + + const mgr = new AgentOutputManager(() => dir, { parentPrefix: "Anna" }); + + expect(await mgr.allocate("Bob")).toBe("Anna.Bob-2"); + expect(await mgr.allocate("Dave")).toBe("Anna.Dave"); + }); +}); diff --git a/packages/coding-agent/test/task/render-nested-live.test.ts b/packages/coding-agent/test/task/render-nested-live.test.ts index 9fc3ddb33..fac5e4431 100644 --- a/packages/coding-agent/test/task/render-nested-live.test.ts +++ b/packages/coding-agent/test/task/render-nested-live.test.ts @@ -99,15 +99,15 @@ describe("task renderer: nested live rendering", () => { it("renders completed nested task results stored in extractedToolData.task while parent is in-progress", async () => { const parent = makeRunningProgress({ - id: "1-Parent", + id: "Parent", recentTools: [{ tool: "task", args: "", endMs: Date.now() }], extractedToolData: { task: [ { projectAgentsDir: null, results: [ - makeCompletedSubResult("1-Parent.0-AlphaSub", "Alpha child"), - makeCompletedSubResult("1-Parent.1-BetaSub", "Beta child"), + makeCompletedSubResult("Parent.AlphaSub", "Alpha child"), + makeCompletedSubResult("Parent.BetaSub", "Beta child"), ], totalDurationMs: 1000, } satisfies TaskToolDetails, @@ -119,12 +119,12 @@ describe("task renderer: nested live rendering", () => { // Parent label is intact. expect(text).toContain("Parent Level 1 work"); - // Both nested completed children labels surface (formatTaskId collapses - // dotted ids → "1.0 Parent>AlphaSub"). + // Both nested completed children labels surface (formatTaskId renders the + // dotted hierarchy as a "Parent>AlphaSub" breadcrumb). expect(text).toContain("Alpha child"); expect(text).toContain("Beta child"); - expect(text).toContain("1.0 Parent>AlphaSub"); - expect(text).toContain("1.1 Parent>BetaSub"); + expect(text).toContain("Parent>AlphaSub"); + expect(text).toContain("Parent>BetaSub"); }); it("renders the in-flight nested task snapshot (progress[]) before the call ends", async () => { @@ -133,12 +133,12 @@ describe("task renderer: nested live rendering", () => { results: [], totalDurationMs: 0, progress: [ - makeRunningSubProgress("2-Parent.0-GammaSub", "Gamma child running"), - makeRunningSubProgress("2-Parent.1-DeltaSub", "Delta child running"), + makeRunningSubProgress("Parent.GammaSub", "Gamma child running"), + makeRunningSubProgress("Parent.DeltaSub", "Delta child running"), ], }; const parent = makeRunningProgress({ - id: "2-Parent", + id: "Parent", currentTool: "task", currentToolStartMs: Date.now(), inflightTaskDetails: inflight, @@ -149,8 +149,8 @@ describe("task renderer: nested live rendering", () => { expect(text).toContain("Parent Level 1 work"); expect(text).toContain("Gamma child running"); expect(text).toContain("Delta child running"); - expect(text).toContain("2.0 Parent>GammaSub"); - expect(text).toContain("2.1 Parent>DeltaSub"); + expect(text).toContain("Parent>GammaSub"); + expect(text).toContain("Parent>DeltaSub"); }); it("combines completed and in-flight nested snapshots in one tree", async () => { @@ -160,7 +160,7 @@ describe("task renderer: nested live rendering", () => { task: [ { projectAgentsDir: null, - results: [makeCompletedSubResult("3.0-EpsilonSub", "Epsilon done")], + results: [makeCompletedSubResult("Parent.EpsilonSub", "Epsilon done")], totalDurationMs: 1000, } satisfies TaskToolDetails, ], @@ -169,7 +169,7 @@ describe("task renderer: nested live rendering", () => { projectAgentsDir: null, results: [], totalDurationMs: 0, - progress: [makeRunningSubProgress("3.1-ZetaSub", "Zeta running")], + progress: [makeRunningSubProgress("Parent.ZetaSub", "Zeta running")], }, }); diff --git a/packages/coding-agent/test/tools/find-validate-paths.test.ts b/packages/coding-agent/test/tools/find-validate-paths.test.ts index ed2167101..2ea467e53 100644 --- a/packages/coding-agent/test/tools/find-validate-paths.test.ts +++ b/packages/coding-agent/test/tools/find-validate-paths.test.ts @@ -1,8 +1,12 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Component } from "@oh-my-pi/pi-tui"; import type { RenderResultOptions } from "../../src/extensibility/custom-tools/types"; import { getThemeByName, initTheme, type Theme } from "../../src/modes/theme/theme"; -import { findToolRenderer, validateFindPathInputs } from "../../src/tools/find"; +import { findToolRenderer } from "../../src/tools/find"; +import { expandDelimitedPathEntries, parseFindPattern, splitDelimitedPathEntry } from "../../src/tools/path-utils"; let uiTheme: Theme; @@ -21,43 +25,79 @@ function renderText(component: Component): string { return Bun.stripANSI(component.render(160).join("\n")); } -describe("validateFindPathInputs", () => { - it("accepts a normal array of glob entries", () => { - expect(() => validateFindPathInputs(["src/**/*.ts", "test/**/*.ts"])).not.toThrow(); +describe("delimited path expansion", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delimited-paths-")); + await fs.mkdir(path.join(tempDir, "apps"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "packages"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "src"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "folder with spaces"), { recursive: true }); + await Bun.write(path.join(tempDir, "apps", "a.txt"), "apps\n"); + await Bun.write(path.join(tempDir, "packages", "b.txt"), "packages\n"); + await Bun.write(path.join(tempDir, "folder with spaces", "file.txt"), "spaces\n"); }); - it('rejects comma-joined entries (the `["a,b"]` shape)', () => { - expect(() => validateFindPathInputs(["a.py,b.py"])).toThrow(/paths is an array/); + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); }); - it("allows commas inside brace expansion", () => { - expect(() => validateFindPathInputs(["src/{a,b}/*.ts"])).not.toThrow(); - expect(() => validateFindPathInputs(["{foo,bar,baz}.md"])).not.toThrow(); + it("splits comma, semicolon, and space delimited entries when parts resolve", async () => { + expect(await splitDelimitedPathEntry("apps/a.txt, packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("apps/a.txt;packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("apps/a.txt packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); }); - it("allows backslash-escaped commas at top level (matches search.ts:containsTopLevelComma)", () => { - // Backslash-escapes a literal comma in a filename — must not trip the - // array-vs-string heuristic. - expect(() => validateFindPathInputs(["weird\\,name.txt"])).not.toThrow(); - expect(() => validateFindPathInputs(["a\\,b\\,c"])).not.toThrow(); + it("keeps an existing path with spaces intact", async () => { + expect(await splitDelimitedPathEntry("folder with spaces/file.txt", tempDir)).toBeNull(); + expect(await expandDelimitedPathEntries(["folder with spaces/file.txt"], tempDir)).toEqual([ + "folder with spaces/file.txt", + ]); }); - it("still rejects unescaped top-level commas mixed with escaped ones", () => { - // `a\,b,c` — the second comma is unescaped, so the heuristic should fire. - expect(() => validateFindPathInputs(["a\\,b,c"])).toThrow(/paths is an array/); + it("does not split commas inside brace globs", async () => { + expect(await splitDelimitedPathEntry("src/{a,b}.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("src/{a,b}.txt, packages/b.txt", tempDir)).toEqual([ + "src/{a,b}.txt", + "packages/b.txt", + ]); }); - it("allows a trailing backslash without crashing", () => { - // `foo\\` is a backslash at end-of-string; the i+1 validateFindPathInputs(["foo\\"])).not.toThrow(); + it("does not split backslash-escaped delimiters", async () => { + expect(await splitDelimitedPathEntry("apps/a.txt\\,packages/b.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("apps/a.txt\\;packages/b.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("folder\\ with\\ spaces/file.txt packages/b.txt", tempDir)).toBeNull(); }); - it("treats `\\{a,b}` as an escaped brace, so the inner comma is still top-level", () => { - // Skip-next semantics: the backslash consumes the `{`, so braceDepth stays 0 - // and the unescaped `,` between `a` and `b` rejects. This pins the literal - // behavior of the new escape-skip, which intentionally does NOT model glob - // brace semantics — it only mirrors search.ts's containsTopLevelComma. - expect(() => validateFindPathInputs(["\\{a,b}"])).toThrow(/paths is an array/); + it("uses strong delimiters leniently and whitespace delimiters conservatively", async () => { + expect(await splitDelimitedPathEntry("missing.txt, packages/b.txt", tempDir)).toEqual([ + "missing.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("missing.txt;packages/b.txt", tempDir)).toEqual([ + "missing.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("missing.txt packages/b.txt", tempDir)).toBeNull(); + }); + + it("cleans trailing strong delimiters and expands glob entries", async () => { + expect(await expandDelimitedPathEntries(["apps/a.txt,"], tempDir)).toEqual(["apps/a.txt"]); + expect( + await expandDelimitedPathEntries(["apps/**/*.txt, packages/**/*.txt"], tempDir, { + splitter: parseFindPattern, + }), + ).toEqual(["apps/**/*.txt", "packages/**/*.txt"]); }); }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index a3b16761b..d23a66c4e 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -154,6 +154,53 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/, packages/, phases/"); }); + it("search expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "search"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing search tool"); + + for (const [name, entry] of [ + ["comma", "apps/grep.txt, packages/grep.txt"], + ["semicolon", "apps/grep.txt;packages/grep.txt"], + ["space", "apps/grep.txt packages/grep.txt"], + ] as const) { + const result = await tool.execute(`search-delimited-${name}`, { + pattern: "shared-needle", + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toMatch(/^# apps\/\n## grep\.txt#[0-9A-F]{4}/m); + expect(text).toMatch(/^# packages\/\n## grep\.txt#[0-9A-F]{4}/m); + expect(text).not.toContain("phases"); + expect(text).not.toContain("other"); + expect(details?.fileCount).toBe(2); + expect(details?.scopePath).toBe("apps/grep.txt, packages/grep.txt"); + } + }); + + it("search keeps comma-delimited surviving entries when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "search"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing search tool"); + + const result = await tool.execute("search-delimited-missing", { + pattern: "shared-needle", + paths: ["missing.txt, packages/grep.txt"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined; + + expect(text).toMatch(/^¶packages\/grep\.txt#[0-9A-F]{4}/m); + expect(text).toContain("Skipped missing paths: missing.txt"); + expect(text).not.toContain("apps"); + expect(details?.fileCount).toBe(1); + expect(details?.missingPaths).toEqual(["missing.txt"]); + }); + it("records hashline snapshots for matched files", async () => { const session = createTestSession(tempDir); const tools = await createTools(session); @@ -404,6 +451,45 @@ describe("tool path arrays", () => { expect(await Bun.file(absoluteTarget).text()).toBe("written\n"); }); + it("read expands comma-delimited paths", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "read"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing read tool"); + + const result = await tool.execute("read-delimited", { + path: "apps/grep.txt, packages/grep.txt", + }); + const text = getText(result); + const details = result.details as { notes?: string[] } | undefined; + + expect(text).toContain("Note: interpreted as 2 paths: apps/grep.txt, packages/grep.txt"); + expect(text).toContain("shared-needle apps"); + expect(text).toContain("shared-needle packages"); + expect(details?.notes).toEqual(["Note: interpreted as 2 paths: apps/grep.txt, packages/grep.txt"]); + }); + + it("read keeps readable delimited paths when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "read"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing read tool"); + + const result = await tool.execute("read-delimited-missing", { + path: "missing.txt, packages/grep.txt", + }); + const text = getText(result); + const details = result.details as { notes?: string[] } | undefined; + + expect(text).toContain("Note: interpreted as 2 paths: missing.txt, packages/grep.txt"); + expect(text).toContain("shared-needle packages"); + expect(text).toContain("[Could not read missing.txt: Path 'missing.txt' not found]"); + expect(details?.notes).toEqual([ + "Note: interpreted as 2 paths: missing.txt, packages/grep.txt", + "Could not read missing.txt: Path 'missing.txt' not found", + ]); + }); + it("ast_grep accepts quoted path and glob filters", async () => { const tools = await createTools(createTestSession(tempDir)); const tool = tools.find(entry => entry.name === "ast_grep"); @@ -444,6 +530,33 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); }); + it("ast_grep expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "ast_grep"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing ast_grep tool"); + + for (const [name, entry] of [ + ["comma", "apps/**/*.ts, packages/**/*.ts"], + ["semicolon", "apps/**/*.ts;packages/**/*.ts"], + ["space", "apps/**/*.ts packages/**/*.ts"], + ] as const) { + const result = await tool.execute(`ast-grep-delimited-${name}`, { + pat: "providerOptions", + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toMatch(/^# apps\/\n## ast\.ts#[0-9A-F]{4}/m); + expect(text).toMatch(/^# packages\/\n## ast\.ts#[0-9A-F]{4}/m); + expect(text).not.toContain("# phases"); + expect(text).not.toContain("# other"); + expect(details?.fileCount).toBe(2); + expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts"); + } + }); + it("ast_edit applies across an explicit path array", async () => { const queue = new ToolChoiceQueue(); const tools = await createTools( @@ -518,6 +631,71 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/, packages/, phases/"); }); + it("find expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + for (const [name, entry] of [ + ["comma", "apps/grep.txt, packages/grep.txt"], + ["semicolon", "apps/grep.txt;packages/grep.txt"], + ["space", "apps/grep.txt packages/grep.txt"], + ] as const) { + const result = await tool.execute(`find-delimited-${name}`, { + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined; + + expect(text).toMatch(/^# apps\/\ngrep\.txt$/m); + expect(text).toMatch(/^# packages\/\ngrep\.txt$/m); + expect(text).not.toContain("phases"); + expect(text).not.toContain("other"); + expect(details?.fileCount).toBe(2); + expect(details?.files).toEqual(expect.arrayContaining(["apps/grep.txt", "packages/grep.txt"])); + expect(details?.scopePath).toBe("apps/grep.txt, packages/grep.txt"); + } + }); + + it("find keeps comma-delimited surviving entries when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + const result = await tool.execute("find-delimited-missing", { + paths: ["missing.txt, packages/grep.txt"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; missingPaths?: string[]; files?: string[] } | undefined; + + expect(text).toMatch(/^# packages\/\ngrep\.txt$/m); + expect(text).toContain("Skipped missing paths: missing.txt"); + expect(text).not.toContain("apps"); + expect(details?.fileCount).toBe(1); + expect(details?.files).toEqual(["packages/grep.txt"]); + expect(details?.missingPaths).toEqual(["missing.txt"]); + }); + + it("find keeps a single path that contains spaces", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + const result = await tool.execute("find-space-directory", { + paths: ["folder with spaces/"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined; + + expect(text).toMatch(/^# folder with spaces\/\nnote\.txt$/m); + expect(details?.fileCount).toBe(1); + expect(details?.files).toEqual(["folder with spaces/note.txt"]); + expect(details?.scopePath).toBe("folder with spaces"); + }); + it("find accepts quoted directory patterns", async () => { const tools = await createTools(createTestSession(tempDir)); const tool = tools.find(entry => entry.name === "find"); diff --git a/packages/coding-agent/test/tools/sqlite.test.ts b/packages/coding-agent/test/tools/sqlite.test.ts index 01a4f3d1f..98e847ebb 100644 --- a/packages/coding-agent/test/tools/sqlite.test.ts +++ b/packages/coding-agent/test/tools/sqlite.test.ts @@ -6,7 +6,13 @@ import * as path from "node:path"; import "../../src/tools/renderers"; import { Settings } from "../../src/config/settings"; import { ReadTool } from "../../src/tools/read"; -import { parseSqlitePathCandidates, parseSqliteSelector, renderTable } from "../../src/tools/sqlite-reader"; +import { + listTables, + parseSqlitePathCandidates, + parseSqliteSelector, + renderTable, + renderTableList, +} from "../../src/tools/sqlite-reader"; import { WriteTool } from "../../src/tools/write"; type ToolTextResult = { @@ -418,3 +424,81 @@ describe("SQLite tool support", () => { ).rejects.toThrow(/no column named 'bogus'/i); }); }); + +describe("SQLite table listing row counts", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sqlite-count-test-")); + dbPath = path.join(tmpDir, "counts.db"); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function seed(rowsPerTable: { big: number; small: number }): void { + const db = new Database(dbPath); + try { + db.run("CREATE TABLE big (id INTEGER PRIMARY KEY, v TEXT NOT NULL)"); + db.run("CREATE TABLE small (id INTEGER PRIMARY KEY)"); + const bigStmt = db.prepare("INSERT INTO big (v) VALUES (?)"); + for (let i = 0; i < rowsPerTable.big; i++) bigStmt.run("x"); + const smallStmt = db.prepare("INSERT INTO small DEFAULT VALUES"); + for (let i = 0; i < rowsPerTable.small; i++) smallStmt.run(); + } finally { + db.close(); + } + } + + function analyze(): void { + const db = new Database(dbPath); + try { + db.run("ANALYZE"); + } finally { + db.close(); + } + } + + it("counts small tables exactly", () => { + seed({ big: 10, small: 2 }); + const db = new Database(dbPath, { readonly: true }); + try { + const rendered = renderTableList(listTables(db, { probeCap: 100 })); + expect(rendered).toContain("big (10 rows)"); + expect(rendered).toContain("small (2 rows)"); + } finally { + db.close(); + } + }); + + it("reports the planner estimate for tables larger than the probe cap", () => { + seed({ big: 10, small: 2 }); + analyze(); + const db = new Database(dbPath, { readonly: true }); + try { + // probeCap=5: big (estimate 10) exceeds it and is reported as an estimate + // without scanning; small (estimate 2) is counted exactly. + const rendered = renderTableList(listTables(db, { probeCap: 5 })); + expect(rendered).toContain("big (~10 rows)"); + expect(rendered).toContain("small (2 rows)"); + } finally { + db.close(); + } + }); + + it("reports a lower bound when an unanalyzed table exceeds the probe cap", () => { + seed({ big: 10, small: 2 }); + const db = new Database(dbPath, { readonly: true }); + try { + // No ANALYZE, so no estimate exists; the bounded probe stops at the cap + // and reports a lower bound instead of scanning the whole table. + const rendered = renderTableList(listTables(db, { probeCap: 3 })); + expect(rendered).toContain("big (3+ rows)"); + expect(rendered).toContain("small (2 rows)"); + } finally { + db.close(); + } + }); +}); diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index df96129f0..1962db2f0 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -4,9 +4,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { runSearchQuery } from "../../src/web/search"; import { buildExaRequestBody, + ExaProvider, normalizeSearchType, searchExa, synthesizeAnswer, @@ -366,246 +366,65 @@ describe("searchExa", () => { expect(result.answer).toContain("**Has URL**: real summary"); }); - it("uses Exa MCP when API key is missing", async () => { + it("requires Exa credentials before starting a search", async () => { delete process.env.EXA_API_KEY; - const fetchSpy = vi.fn(async (_input, _init, _next) => { - return new Response(JSON.stringify({ jsonrpc: "2.0", id: "mcp-1", result: makeMockExaResponse() }), { + const fetchSpy = vi.fn(async () => { + return new Response(JSON.stringify(makeMockExaResponse()), { status: 200, headers: { "Content-Type": "application/json" }, }); }); using _hook = hookFetch(fetchSpy); - const result = await searchExa({ query: "no key" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(3); - - const calledUrl = String(fetchSpy.mock.calls[0][0]); - expect(calledUrl).toContain("https://mcp.exa.ai/mcp"); - expect(calledUrl).toContain("tools=web_search_exa"); - expect(calledUrl).not.toContain("exaApiKey="); - }); - - it("accepts MCP structuredContent search payloads when API key is missing", async () => { - delete process.env.EXA_API_KEY; - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-structured", - result: { structuredContent: makeMockExaResponse() }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - const result = await searchExa({ query: "structured payload" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(3); - expect(result.answer).toContain("**Page Alpha**: Alpha is about X."); - }); - - it("accepts MCP text content JSON payloads when API key is missing", async () => { - delete process.env.EXA_API_KEY; - const payload = makeMockExaResponse(); - const fetchSpy = vi.fn(async (_input, _init, _next) => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-content", - result: { - content: [{ type: "text", text: JSON.stringify(payload) }], - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - using _hook = hookFetch(fetchSpy); - - const result = await searchExa({ query: "content payload" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(3); - expect(result.answer).toContain("**Page Beta**: Beta covers Y."); - - const calledUrl = String(fetchSpy.mock.calls[0][0]); - expect(calledUrl).not.toContain("exaApiKey="); - }); - - it("accepts MCP text content plain-text payloads when API key is missing", async () => { - delete process.env.EXA_API_KEY; - const payloadText = [ - "Title: Plain Alpha", - "URL: https://plain-alpha.com", - "Author: Alpha Author", - "Published Date: 2024-01-02", - "Text: Alpha snippet", - "", - "Title: Plain Beta", - "URL: https://plain-beta.com", - "Text: Beta snippet", - ].join("\n"); - const fetchSpy = vi.fn(async (_input, _init, _next) => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-content-plain-text", - result: { content: [{ type: "text", text: payloadText }] }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - using _hook = hookFetch(fetchSpy); - - const result = await searchExa({ query: "plain text content payload" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(2); - expect(result.sources[0]).toMatchObject({ - title: "Plain Alpha", - url: "https://plain-alpha.com", - author: "Alpha Author", - snippet: "Alpha snippet", - publishedDate: "2024-01-02", - }); - expect(result.sources[1]).toMatchObject({ - title: "Plain Beta", - url: "https://plain-beta.com", - snippet: "Beta snippet", - }); - expect(result.answer).toBeUndefined(); - - const calledUrl = String(fetchSpy.mock.calls[0][0]); - expect(calledUrl).not.toContain("exaApiKey="); - }); - - it("splits MCP plain-text records with CRLF line endings", async () => { - delete process.env.EXA_API_KEY; - const payloadText = [ - "Title: CRLF Alpha", - "URL: https://crlf-alpha.com", - "Text: First result", - "", - "Title: CRLF Beta", - "URL: https://crlf-beta.com", - "Text: Second result", - ].join("\r\n"); - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-content-crlf", - result: { content: [{ type: "text", text: payloadText }] }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - const result = await searchExa({ query: "crlf payload" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(2); - expect(result.sources[0]?.url).toBe("https://crlf-alpha.com"); - expect(result.sources[1]?.url).toBe("https://crlf-beta.com"); - }); - - it("keeps 'Title:' lines inside Text body when parsing MCP plain-text content", async () => { - delete process.env.EXA_API_KEY; - const payloadText = [ - "Title: Plain Alpha", - "URL: https://plain-alpha.com", - "Text: Alpha line 1", - "Title: heading inside body", - "Alpha line 2", - "", - "Title: Plain Beta", - "URL: https://plain-beta.com", - "Text: Beta snippet", - ].join("\n"); - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-content-embedded-title", - result: { content: [{ type: "text", text: payloadText }] }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - const result = await searchExa({ query: "embedded title line" }); - expect(result.provider).toBe("exa"); - expect(result.sources).toHaveLength(2); - expect(result.sources[0]?.snippet).toContain("Title: heading inside body"); - expect(result.sources[0]?.snippet).toContain("Alpha line 2"); - }); - - it("runSearchQuery with provider=exa succeeds without EXA_API_KEY for MCP plain text content", async () => { - delete process.env.EXA_API_KEY; - const payloadText = [ - "Title: Result One", - "URL: https://result-one.com", - "Text: First plain-text result", - "", - "Title: Result Two", - "URL: https://result-two.com", - "Text: Second plain-text result", - ].join("\n"); - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-tool-plain-text", - result: { content: [{ type: "text", text: payloadText }] }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - const result = await withLocalAuthStorage(authStorage => - runSearchQuery({ query: "provider exa plain text", provider: "exa" }, { authStorage }), + await expect(searchExa({ query: "no key" })).rejects.toThrow( + "Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.", ); - expect(result.details.error).toBeUndefined(); - expect(result.details.response.provider).toBe("exa"); - expect(result.details.response.sources).toHaveLength(2); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("runSearchQuery with provider=exa succeeds without EXA_API_KEY for MCP structuredContent", async () => { + it("uses AuthStorage credentials when EXA_API_KEY is unset", async () => { delete process.env.EXA_API_KEY; - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-tool", - result: { structuredContent: makeMockExaResponse() }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + let receivedKey: string | undefined; + using _hook = hookFetch((_url, init) => { + receivedKey = (init?.headers as Record | undefined)?.["x-api-key"]; + return new Response(JSON.stringify(makeMockExaResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }); - const result = await withLocalAuthStorage(authStorage => - runSearchQuery({ query: "provider exa", provider: "exa" }, { authStorage }), - ); - expect(result.details.error).toBeUndefined(); - expect(result.details.response.provider).toBe("exa"); - expect(result.content[0]?.text).toContain("3 sources"); + await withLocalAuthStorage(async authStorage => { + authStorage.setRuntimeApiKey("exa", "stored-key-xyz"); + const result = await searchExa({ query: "from auth storage", authStorage }); + expect(result.provider).toBe("exa"); + expect(result.sources).toHaveLength(3); + }); + expect(receivedKey).toBe("stored-key-xyz"); }); - it("throws clear error when MCP content payload is not parseable JSON", async () => { + it("reports unavailable without EXA_API_KEY or stored credentials", async () => { delete process.env.EXA_API_KEY; - using _hook = hookFetch(async () => { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "mcp-bad-content", - result: { - content: [{ type: "text", text: "not-json" }], - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - await expect(searchExa({ query: "bad content" })).rejects.toThrow( - "Exa MCP search returned unexpected response shape.", + const available = await withLocalAuthStorage(authStorage => + Promise.resolve(new ExaProvider().isAvailable(authStorage)), ); + expect(available).toBe(false); + }); + + it("reports available with EXA_API_KEY", async () => { + process.env.EXA_API_KEY = "test-key-123"; + const available = await withLocalAuthStorage(authStorage => + Promise.resolve(new ExaProvider().isAvailable(authStorage)), + ); + expect(available).toBe(true); + }); + + it("reports available when AuthStorage holds a credential", async () => { + delete process.env.EXA_API_KEY; + const available = await withLocalAuthStorage(authStorage => { + authStorage.setRuntimeApiKey("exa", "stored-key"); + return Promise.resolve(new ExaProvider().isAvailable(authStorage)); + }); + expect(available).toBe(true); }); it("throws SearchProviderError on non-ok HTTP response", async () => { diff --git a/packages/coding-agent/test/tools/web-search-kagi.test.ts b/packages/coding-agent/test/tools/web-search-kagi.test.ts index f783b9f83..729bfa1c0 100644 --- a/packages/coding-agent/test/tools/web-search-kagi.test.ts +++ b/packages/coding-agent/test/tools/web-search-kagi.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test"; import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { searchWithKagi } from "../../src/web/kagi"; -import { searchKagi } from "../../src/web/search/providers/kagi"; +import { type KagiSearchRequest, searchWithKagi } from "../../src/web/kagi"; +import { KagiProvider, searchKagi } from "../../src/web/search/providers/kagi"; import { SearchProviderError } from "../../src/web/search/types"; const fakeAuthStorage = { @@ -24,20 +24,17 @@ describe("Kagi web search error handling", () => { delete process.env.KAGI_API_KEY; }); - it("surfaces beta access denial messages from JSON error bodies", async () => { - const providerMessage = - "Kagi Search API is in beta. Please contact support@kagi.com to enable API access for your account."; - + it("maps auth failures to a compact provider-tagged error", async () => { using _hook = hookFetch( () => - new Response(JSON.stringify({ error: [{ code: 401, msg: providerMessage }] }), { + new Response(JSON.stringify({ error: [{ code: 401, message: "Invalid API key or access denied." }] }), { status: 401, headers: { "Content-Type": "application/json" }, }), ); try { - await searchKagi({ query: "kagi beta", authStorage: fakeAuthStorage }); + await searchKagi({ query: "kagi test", authStorage: fakeAuthStorage }); expect.unreachable("expected searchKagi to throw"); } catch (error) { expect(error).toBeInstanceOf(SearchProviderError); @@ -47,45 +44,201 @@ describe("Kagi web search error handling", () => { }); it("falls back to plain text for non-JSON error bodies", async () => { - using _hook = hookFetch(() => new Response("upstream unavailable", { status: 503 })); + using _hook = hookFetch(() => new Response("service unavailable", { status: 503 })); await expect(searchWithKagi("plain text error", {}, fakeAuthStorage)).rejects.toThrow( - "Kagi API error (503): upstream unavailable", + "Kagi API error (503): service unavailable", ); }); - it("preserves successful search parsing", async () => { + it("maps HTTP 5xx errors with empty body", async () => { + using _hook = hookFetch(() => new Response("", { status: 502 })); + + await expect(searchWithKagi("empty error", {}, fakeAuthStorage)).rejects.toThrow("Kagi API error (502)"); + }); +}); + +describe("Kagi search result parsing", () => { + beforeEach(() => { + process.env.KAGI_API_KEY = "test-kagi-key"; + setSystemTime(new Date("2026-05-25T00:00:00Z")); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.KAGI_API_KEY; + setSystemTime(); + }); + + it("parses categorized response with search + video + news + related_search", async () => { using _hook = hookFetch( () => new Response( JSON.stringify({ - meta: { id: "req-kagi-success" }, - data: [ - { - t: 0, - url: "https://example.com/article", - title: "Example Article", - snippet: "Example snippet", - published: "2025-01-01T00:00:00Z", - }, - { t: 1, list: ["What is Kagi Search API beta access?"] }, - ], + meta: { trace: "req-success" }, + data: { + search: [ + { + url: "https://example.com/article", + title: "Example Article", + snippet: "Example snippet text", + time: "2025-06-01T00:00:00Z", + }, + ], + video: [ + { + url: "https://example.com/video", + title: "Example Video", + snippet: "Video description", + time: "2025-06-02T00:00:00Z", + }, + ], + news: [ + { + url: "https://example.com/news", + title: "Breaking News", + snippet: "News snippet", + time: "2025-06-03T00:00:00Z", + }, + ], + related_search: [ + { + title: "Related One", + url: "https://example.com/rs1", + props: { question: "related query one" }, + }, + { + title: "Related Two", + url: "https://example.com/rs2", + props: { question: "related query two" }, + }, + ], + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); - await expect(searchWithKagi("success case", {}, fakeAuthStorage)).resolves.toEqual({ - requestId: "req-kagi-success", - sources: [ - { - title: "Example Article", - url: "https://example.com/article", - snippet: "Example snippet", - publishedDate: "2025-01-01T00:00:00Z", - }, - ], - relatedQuestions: ["What is Kagi Search API beta access?"], + const result = await searchWithKagi("success case", {}, fakeAuthStorage); + + expect(result.requestId).toBe("req-success"); + expect(result.sources).toHaveLength(3); + expect(result.sources[0]).toMatchObject({ + title: "Example Article", + url: "https://example.com/article", + snippet: "Example snippet text", + publishedDate: "2025-06-01T00:00:00Z", }); + expect(result.sources[1]).toMatchObject({ title: "[Video] Example Video", url: "https://example.com/video" }); + expect(result.sources[2]).toMatchObject({ title: "[News] Breaking News", url: "https://example.com/news" }); + expect(result.relatedQuestions).toEqual(["related query one", "related query two"]); + expect(result.answer).toBeUndefined(); + }); + + it("parses direct_answer into the answer field", async () => { + using _hook = hookFetch( + () => + new Response( + JSON.stringify({ + meta: { trace: "req-answer" }, + data: { + search: [{ url: "https://example.com", title: "Result", snippet: "Snippet" }], + direct_answer: [ + { + url: "https://example.com/answer", + title: "Direct Answer", + snippet: "This is a direct answer.", + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const result = await searchWithKagi("question", {}, fakeAuthStorage); + + expect(result.answer).toBe("This is a direct answer."); + }); + + it("returns empty results for an empty data object", async () => { + using _hook = hookFetch( + () => + new Response(JSON.stringify({ meta: { trace: "req-empty" }, data: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await searchWithKagi("no results", {}, fakeAuthStorage); + + expect(result.sources).toHaveLength(0); + expect(result.relatedQuestions).toHaveLength(0); + expect(result.answer).toBeUndefined(); + }); + + it.each([ + ["day", "2026-05-24"], + ["week", "2026-05-18"], + ["month", "2026-04-25"], + ["year", "2025-05-25"], + ] as const)("maps recency %s to filters.after %s", async (recency, expected) => { + let requestBody: KagiSearchRequest | undefined; + + using _hook = hookFetch((input: string | URL | Request, init) => { + const urlStr = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr === "https://kagi.com/api/v1/search") { + requestBody = JSON.parse(init?.body as string) as KagiSearchRequest; + return new Response(JSON.stringify({ meta: { trace: "req-recency" }, data: { search: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not mocked", { status: 500 }); + }); + + await searchWithKagi("recency test", { recency }, fakeAuthStorage); + + expect(requestBody?.filters?.after).toBe(expected); + }); + + it("uses a Bearer authorization header", async () => { + let capturedAuth: string | null = null; + using _hook = hookFetch((input: string | URL | Request, init) => { + const urlStr = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr === "https://kagi.com/api/v1/search") { + capturedAuth = + init?.headers instanceof Headers + ? init.headers.get("Authorization") + : typeof init?.headers === "object" && init?.headers !== null + ? (init.headers as Record).Authorization + : null; + return new Response(JSON.stringify({ meta: { trace: "req-auth" }, data: { search: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not mocked", { status: 500 }); + }); + + await searchWithKagi("auth test", {}, fakeAuthStorage); + + expect(capturedAuth ?? "null").toBe("Bearer test-kagi-key"); + }); +}); + +describe("KagiProvider.isAvailable", () => { + afterEach(() => { + delete process.env.KAGI_API_KEY; + }); + + it("returns true when a credential is present", () => { + process.env.KAGI_API_KEY = "test-key"; + expect(new KagiProvider().isAvailable(fakeAuthStorage)).toBe(true); + }); + + it("returns false when no credential is present", () => { + delete process.env.KAGI_API_KEY; + expect(new KagiProvider().isAvailable(fakeAuthStorage)).toBe(false); }); }); diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 90965536f..038fcba15 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { replaceBinaryForUpdate, resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { buildBunInstallArgs, replaceBinaryForUpdate, resolveUpdateMethodForTest } from "../src/cli/update-cli"; const tempDirs: string[] = []; @@ -35,6 +35,26 @@ describe("update-cli install target detection", () => { }); }); +describe("update-cli bun install command", () => { + it("pins the official npm registry and bypasses the manifest cache so a stale mirror or snapshot cannot mask a freshly published version", () => { + // Regression: omp queries https://registry.npmjs.org//latest directly. + // The install MUST hit the same registry, otherwise: + // - a lagging mirror (corp proxy, Taobao, …) rejects the version with + // `No version matching "X" (but package exists)`, + // - or bun's local manifest snapshot does the same when the user's bun + // is already pointed at the official registry but its cache predates + // the release. + // See https://github.com/can1357/oh-my-pi/issues/1686. + expect(buildBunInstallArgs("15.7.6")).toEqual([ + "install", + "-g", + "--no-cache", + "--registry=https://registry.npmjs.org/", + "@oh-my-pi/pi-coding-agent@15.7.6", + ]); + }); +}); + describe("update-cli binary replacement", () => { it("restores the previous binary when the replacement fails verification", async () => { const dir = await makeTempDir(); diff --git a/packages/coding-agent/test/utils/open.test.ts b/packages/coding-agent/test/utils/open.test.ts new file mode 100644 index 000000000..0943f065f --- /dev/null +++ b/packages/coding-agent/test/utils/open.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as piUtils from "@oh-my-pi/pi-utils"; +import type { Subprocess } from "bun"; +import { openPath } from "../../src/utils/open"; + +type SpawnOptions = Bun.SpawnOptions.SpawnOptions< + Bun.SpawnOptions.Writable, + Bun.SpawnOptions.Readable, + Bun.SpawnOptions.Readable +>; +type SpawnCall = { cmd: string[]; options: SpawnOptions }; + +type SpawnSyncOptions = Bun.SpawnOptions.SpawnSyncOptions<"ignore", "pipe", "ignore">; + +const existingLinuxPath = "/mnt/c/Users/example/Downloads/session.html"; +const windowsPath = "C:\\Users\\example\\Downloads\\session.html"; + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); +const ENV_KEYS = ["WSL_DISTRO_NAME", "WSL_INTEROP"] as const; +let savedEnv: Partial> = {}; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +function restorePlatform(): void { + if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor); +} + +function fakeProcess(): Subprocess { + return { + pid: 1, + exited: Promise.resolve(0), + kill: () => true, + } as unknown as Subprocess; +} + +function spySpawn(calls: SpawnCall[]) { + function mockSpawn(opts: SpawnOptions & { cmd: string[] }): Subprocess; + function mockSpawn(cmd: string[], opts?: SpawnOptions): Subprocess; + function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), second?: SpawnOptions): Subprocess { + const cmd = Array.isArray(first) ? first : first.cmd; + const options = Array.isArray(first) ? (second ?? ({} as SpawnOptions)) : (first as SpawnOptions); + calls.push({ cmd, options }); + return fakeProcess(); + } + return vi.spyOn(Bun, "spawn").mockImplementation(mockSpawn); +} + +function spyWslPath(calls: string[][], output: string, exitCode = 0) { + const result = { + stdout: Buffer.from(output), + stderr: null, + exitCode, + success: exitCode === 0, + resourceUsage: {}, + pid: 1, + } as unknown as Bun.SyncSubprocess<"pipe", "ignore">; + + function mockSpawnSync(opts: SpawnSyncOptions & { cmd: string[] }): Bun.SyncSubprocess<"pipe", "ignore">; + function mockSpawnSync(cmd: string[], opts?: SpawnSyncOptions): Bun.SyncSubprocess<"pipe", "ignore">; + function mockSpawnSync( + first: string[] | (SpawnSyncOptions & { cmd: string[] }), + ): Bun.SyncSubprocess<"pipe", "ignore"> { + calls.push(Array.isArray(first) ? first : first.cmd); + return result; + } + return vi.spyOn(Bun, "spawnSync").mockImplementation(mockSpawnSync); +} + +beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const prior = savedEnv[key]; + if (prior === undefined) delete process.env[key]; + else process.env[key] = prior; + } + restorePlatform(); + vi.restoreAllMocks(); +}); + +describe("openPath", () => { + it("opens existing WSL mount files through wslview with a Windows path", () => { + setPlatform("linux"); + process.env.WSL_DISTRO_NAME = "Ubuntu"; + vi.spyOn(piUtils, "$which").mockImplementation(command => (command === "wslview" ? "/usr/bin/wslview" : null)); + vi.spyOn(fs, "existsSync").mockImplementation(candidate => candidate === existingLinuxPath); + + const spawnSyncCalls: string[][] = []; + spyWslPath(spawnSyncCalls, windowsPath); + const spawnCalls: SpawnCall[] = []; + spySpawn(spawnCalls); + + openPath(existingLinuxPath); + + expect(spawnSyncCalls).toEqual([["wslpath", "-w", existingLinuxPath]]); + expect(spawnCalls.map(call => call.cmd)).toEqual([["wslview", windowsPath]]); + }); + + it("keeps WSL URL opening on xdg-open without path conversion", () => { + setPlatform("linux"); + process.env.WSL_INTEROP = "/run/WSL/1_interop"; + vi.spyOn(piUtils, "$which").mockReturnValue("/usr/bin/wslview"); + const spawnSyncSpy = vi.spyOn(Bun, "spawnSync"); + const spawnCalls: SpawnCall[] = []; + spySpawn(spawnCalls); + + openPath("https://example.com"); + + expect(spawnSyncSpy).not.toHaveBeenCalled(); + expect(spawnCalls.map(call => call.cmd)).toEqual([["xdg-open", "https://example.com"]]); + }); + + it("falls back to xdg-open when wslview is unavailable", () => { + setPlatform("linux"); + process.env.WSL_DISTRO_NAME = "Ubuntu"; + vi.spyOn(piUtils, "$which").mockReturnValue(null); + const spawnSyncSpy = vi.spyOn(Bun, "spawnSync"); + const spawnCalls: SpawnCall[] = []; + spySpawn(spawnCalls); + + openPath(existingLinuxPath); + + expect(spawnSyncSpy).not.toHaveBeenCalled(); + expect(spawnCalls.map(call => call.cmd)).toEqual([["xdg-open", existingLinuxPath]]); + }); +}); diff --git a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts index c269628d6..5d9dc0e03 100644 --- a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts +++ b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts @@ -12,6 +12,7 @@ * helper itself is exercised directly. */ import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { hookFetch } from "@oh-my-pi/pi-utils"; import type { AgentStorage } from "../../../src/session/agent-storage"; import type { ToolSession } from "../../../src/tools"; @@ -60,6 +61,7 @@ describe("Anthropic provider hard-timeout wiring", () => { afterEach(() => { vi.restoreAllMocks(); delete process.env.ANTHROPIC_SEARCH_API_KEY; + delete process.env.ANTHROPIC_SEARCH_BASE_URL; }); it("passes a composed signal to fetch even when the caller did not supply one", async () => { @@ -104,6 +106,28 @@ describe("Anthropic provider hard-timeout wiring", () => { expect(capturedSignal).toBeInstanceOf(AbortSignal); expect(capturedSignal).not.toBe(ac.signal); }); + it("applies ANTHROPIC_SEARCH_BASE_URL to stored Anthropic credentials", async () => { + process.env.ANTHROPIC_SEARCH_BASE_URL = "https://search.example.test/"; + + let capturedUrl: string | undefined; + using _hook = hookFetch(async input => { + capturedUrl = String(input); + return new Response(JSON.stringify({ content: [{ type: "text", text: "ok" }], usage: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchAnthropic({ + query: "ping", + systemPrompt: "", + authStorage: { + getApiKey: async () => "sk-fallback", + } as unknown as AuthStorage, + }); + + expect(capturedUrl).toBe("https://search.example.test/v1/messages?beta=true"); + }); }); describe("Brave provider hard-timeout wiring", () => { diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 7245716e2..3d3c1c0ac 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + +### Fixed + +- Fixed hashline replacements that accidentally restated unchanged lines above and below the selected range so they no longer duplicate both boundary lines ([#1664](https://github.com/can1357/oh-my-pi/issues/1664)). + ## [15.7.0] - 2026-05-31 ### Added diff --git a/packages/hashline/package.json b/packages/hashline/package.json index e22f4e14d..c915eaa60 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "15.7.6", + "version": "15.8.0", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 35e69be61..14c519e24 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -3,9 +3,9 @@ * post-edit lines plus any diagnostic warnings. Pure function: no FS, no * mutation of the input. * - * Replacement groups are first normalized by {@link repairBoundaryBalance}, - * which fixes the common model mistake of a payload that duplicates or drops - * the closing delimiter bordering the range (balance-validated; see below). + * Replacement groups are first normalized by {@link repairReplacementBoundaries}, + * which absorbs common model mistakes where a payload restates unchanged range + * boundaries or duplicates/drops structural closers. */ import { UNRESOLVED_BLOCK_INTERNAL } from "./messages"; import { cloneCursor } from "./tokenizer"; @@ -98,22 +98,19 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map= 1; count--) { + let matches = true; + let hasContent = false; + for (let offset = 0; offset < count; offset++) { + const line = payload[offset]; + if (line !== fileLines[startLine - 1 - count + offset]) { + matches = false; + break; + } + hasContent ||= hasNonWhitespace(line); + } + if (matches && hasContent) return count; + } + return 0; +} + +function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number { + const { payload, endLine } = group; + const max = Math.min(payload.length, fileLines.length - endLine); + for (let count = max; count >= 1; count--) { + let matches = true; + let hasContent = false; + for (let offset = 0; offset < count; offset++) { + const line = payload[payload.length - count + offset]; + if (line !== fileLines[endLine + offset]) { + matches = false; + break; + } + hasContent ||= hasNonWhitespace(line); + } + if (matches && hasContent) return count; + } + return 0; +} + +function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined { + const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines); + if (leadingMax === 0) return undefined; + const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines); + if (trailingMax === 0) return undefined; + // Bail when every payload line could be claimed by a boundary echo: any + // repair would strip explicit replacement content with no signal that the + // payload was a mistake rather than an intentional duplication. + if (leadingMax + trailingMax >= group.payload.length) return undefined; + return { leading: leadingMax, trailing: trailingMax }; +} + +function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string { + return ( + `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` + + `dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` + + `Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.` + ); +} + function describeBoundaryRepair(group: ReplacementGroup, action: string): string { return ( `Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` + @@ -330,11 +398,11 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string } /** - * Normalize each replacement group so its payload preserves the deleted - * region's delimiter balance. See the section header for the contract. Returns - * the (possibly trimmed) edit list plus one warning per repaired group. + * Normalize replacement groups so common off-by-one boundaries do not duplicate + * unchanged surrounding lines or structural closers. Returns the repaired edit + * list plus one warning per repaired group. */ -function repairBoundaryBalance( +function repairReplacementBoundaries( edits: readonly AppliedEdit[], fileLines: readonly string[], ): { @@ -355,6 +423,13 @@ function repairBoundaryBalance( const deletes = group.deleteIndices.map(idx => edits[idx]); i = group.deleteIndices[group.deleteIndices.length - 1] + 1; + const boundaryEcho = findBoundaryEcho(group, fileLines); + if (boundaryEcho) { + warnings.push(describeBoundaryEchoRepair(group, boundaryEcho)); + out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes); + continue; + } + const delta = balanceDelta( computeDelimiterBalance(group.payload), computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)), @@ -429,7 +504,7 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult { const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)); validateLineBounds(targetEdits, fileLines); - const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines); + const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines); // Partition edits into bof, eof, and anchor-targeted buckets. const bofLines: string[] = []; diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index 9d62bae0c..38468a116 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -83,6 +83,56 @@ describe("boundary-balance repair", () => { expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); }); + it("drops duplicated leading and trailing boundary lines around a range replacement", () => { + const file = [ + "func _cmd_travel_homeworld():", + "\tvar destination = get_homeworld()", + "\ttravel_to(destination)", + "\tprint_status()", + ].join("\n"); + const diff = [ + "replace 2..3:", + "+func _cmd_travel_homeworld():", + "+\tvar destination = find_homeworld()", + "+\ttravel_to(destination)", + "+\tprint_status()", + ].join("\n"); + + const { text, warnings } = apply(file, diff); + + expect(text).toBe( + [ + "func _cmd_travel_homeworld():", + "\tvar destination = find_homeworld()", + "\ttravel_to(destination)", + "\tprint_status()", + ].join("\n"), + ); + expect(text.split("\n").filter(line => line === "func _cmd_travel_homeworld():")).toHaveLength(1); + expect(text.split("\n").filter(line => line === "\tprint_status()")).toHaveLength(1); + expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + }); + + it("preserves payloads where multi-line boundary echoes cover every line", () => { + const file = ["A", "B", "old", "C", "D"].join("\n"); + const diff = ["replace 3..3:", "+A", "+B", "+C", "+D"].join("\n"); + + const { text, warnings } = apply(file, diff); + + expect(text).toBe(["A", "B", "A", "B", "C", "D", "C", "D"].join("\n")); + expect(warnings).toHaveLength(0); + }); + + it("preserves payloads made only of lines matching both replacement neighbors", () => { + const file = ["a", "old", "c"].join("\n"); + const diff = ["replace 2..2:", "+a", "+c"].join("\n"); + + const { text, warnings } = apply(file, diff); + + expect(text).toBe(["a", "a", "c", "c"].join("\n")); + expect(warnings).toHaveLength(0); + }); + // Balance-preserving edits are never touched, even when the payload's last // line coincidentally equals the line just below the range. it("leaves a balance-preserving replacement alone (no false positive)", () => { diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 8f16aac3d..70fa7e5be 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "15.7.6", + "version": "15.8.0", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index d887408f9..a1f2b3b7d 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ## [15.7.0] - 2026-05-31 + ### Added - Added `blockRangeAt` native API along with `BlockRange` and `BlockRangeOptions` types to return the 1-indexed line span of the outermost tree-sitter node beginning on a given line @@ -24,6 +25,7 @@ - Fixed background bash jobs pinning the JS main thread at ~200% CPU when the child process emits output in many tiny writes (printf-style progress, llama-cli token streams). `pi_shell`'s pipe reader forwarded every chunk through a separate `ThreadsafeFunction::call` per kernel `read(2)`, so a chatty child produced millions of cross-thread napi callbacks that the JS main thread had to drain serially — even after the child exited, the queue kept the process saturated for seconds. The bridge now greedily coalesces every chunk already in the mpsc queue into a single batched call (capped at 64 KiB) before crossing into JS, collapsing 1-byte writes into one napi dispatch and bringing the steady-state callback rate back to the JS event-loop's throughput. ## [15.5.9] - 2026-05-28 + ### Changed - Changed native addon extraction to skip re-extracting cached `.node` files when their size already matches embedded archive metadata @@ -38,6 +40,7 @@ - Hardened embedded addon archive extraction by rejecting unsafe entry names and non-file archive entries before writing binaries to disk ## [15.5.4] - 2026-05-27 + ### Added - Added `Hashline` class with methods to format headers, parse/apply hashline edits, split inputs, compute diffs, generate previews, and recover from stale hashes @@ -67,12 +70,10 @@ ### Fixed - Fixed ` is not a function` crashes on Windows after `bun install -g @oh-my-pi/pi-coding-agent` updates while an `omp` process was running. Bun cannot overwrite a locked `node_modules/@oh-my-pi/pi-natives/native/pi_natives.win32-x64.node` and silently keeps the old binary alongside the new ESM wrapper, so the next launch loads mismatched code. The loader now mirrors the addon into `~/.omp/natives//` on Windows npm installs and prefers that copy at load time — each version gets its own filesystem path, so future updates land in `node_modules` unchallenged. The new version sentinel detects any remaining drift up front. - -### Fixed - - Fixed `$env:NAME` PowerShell references being collapsed to `:NAME` when brush forwarded a command to a PowerShell (or any) subprocess. `pi-shell` now defines `env=$env` as a non-exported global on every brush session so the bash parameter expansion of `$env` yields the literal `$env`, leaving `$env:NAME` intact. User-driven assignments (`env=prod`) push their own command-scope binding and shadow the fallback, preserving the bash POSIX contract. ([#1079](https://github.com/can1357/oh-my-pi/issues/1079)) ## [15.0.1] - 2026-05-14 + ### Breaking Changes - Raised the minimum required Bun runtime version to >=1.3.14 @@ -80,6 +81,7 @@ - Removed `webp` Rust workspace dependency along with `PhotonImage`'s WebP encoder. ## [14.9.9] - 2026-05-12 + ### Breaking Changes - Removed `projfsOverlayProbe`, `projfsOverlayStart`, and `projfsOverlayStop` overlays APIs and `ProjfsOverlayProbeResult` type from the public natives interface @@ -105,6 +107,7 @@ - Removed the 20 Hz background descendant tracker that scanned the harness's process tree for the entire lifetime of every shell command. Cancellation now does a small rescan-and-signal loop on demand (up to three waves — SIGTERM, then SIGKILL, then SIGKILL — with early exit as soon as no descendants remain). The previous tracker existed to pin process identities against PID reuse races, but `Process::from_pid` already pins identity by kernel start time / pidfd, so the constant scanning paid for nothing and added meaningful syscall load on macOS where each scan now does `proc_listallpids` + `proc_pidinfo` per pid. ## [14.9.3] - 2026-05-10 + ### Added - Added `idle`, `system`, and `user` options to `MacOSPowerAssertion` so callers can request specific macOS sleep-prevention modes (`caffeinate -i`, `-s`, and `-u`) in addition to the existing `display` option @@ -136,6 +139,7 @@ - Fixed native `grep` count-mode limits applying to files instead of matches, and restored timeout/abort cancellation checks for small native filesystem scans. ## [14.7.0] - 2026-05-04 + ### Added - Added `summarizeCode` function to expose native code summarization with `kind`, `startLine`, `endLine`, and optional `text` segments plus parse/elision metadata @@ -143,11 +147,13 @@ - Added `SummaryOptions` and `SummaryResult` TypeScript definitions for typed `summarizeCode` input and output ## [14.6.1] - 2026-05-02 + ### Changed - Changed the native package loader from CommonJS analyzer-visible assignments to a template-rendered ESM entry point with explicit named exports ## [14.5.13] - 2026-05-01 + ### Changed - Stopped overriding `CARGO_TARGET_DIR` with an internal `target/napi-build/...` directory during native builds, so Cargo now uses the default or caller-provided target directory @@ -160,6 +166,7 @@ - Removed the `ci-release-verify-natives` script and its AVX-512 marker scan from the release pipeline ## [14.5.12] - 2026-04-30 + ### Breaking Changes - Changed `waitForExit` to accept a single options object instead of a numeric timeout argument @@ -171,6 +178,7 @@ - Added a `ProcessWaitOptions` type and updated `waitForExit` to accept an options object ## [14.5.9] - 2026-04-30 + ### Fixed - Fixed shell minimizer output so successful commands whose noise is fully stripped still return `OK` instead of an artifact-only result @@ -182,6 +190,7 @@ - Added shell minimizer support for CMake, CTest, Ninja, GoogleTest binaries, and Bun/Bunx wrappers that run those tools ## [14.5.2] - 2026-04-26 + ### Changed - Changed local native build profile from `dev` to `local` for non-CI builds, updating the profile used by the build and local build output label @@ -193,6 +202,7 @@ - Removed the `chunk` napi module (`ChunkState`, chunk schema, chunk rendering, chunk edit) and dropped `generate_chunk_schema()` from the build script ## [14.3.0] - 2026-04-25 + ### Added - Added `text` to `MinimizerResult` so consumers can replace rewritten output with the minimized replacement text @@ -237,6 +247,7 @@ - Removed the `fuzzyFind`, `glob`, and `grep` cache database argument previously used for search state ## [14.0.5] - 2026-04-11 + ### Breaking Changes - Made `tabWidth` parameter required (no longer optional) for `visibleWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `sliceWithWidth`, and `extractSegments` @@ -332,11 +343,13 @@ - Updated `grep()`, `glob()`, and `fuzzyFind()` function signatures to accept optional `db` parameter for database-backed searching ## [13.12.0] - 2026-03-14 + ### Breaking Changes - Changed `abort()` method signature: removed optional `reason` parameter and changed return type from `void` to `Promise` ## [13.4.0] - 2026-03-01 + ### Breaking Changes - Changed `AstFindOptions.pattern` to `patterns` (now accepts array of strings instead of single string) @@ -349,6 +362,7 @@ - Result ordering in `astGrep` is now deterministic: sorted by path, line, column using `BTreeSet`/`BTreeMap` ## [13.3.8] - 2026-02-28 + ### Added - Added `astGrep()` function for structural code search using AST patterns with support for language-specific matching, selectors, and meta-variable extraction @@ -356,6 +370,7 @@ - Added `./ast` export path for accessing AST search and rewrite functionality ## [12.18.0] - 2026-02-21 + ### Changed - Replaced custom `TextDecoder` usage with native `toString('utf-8')` for buffer decoding @@ -375,16 +390,19 @@ - Added README.md to package distribution files ## [12.10.0] - 2026-02-18 + ### Changed - Updated addon filename resolution to include default filename fallback in both modern and baseline variant paths ## [12.8.2] - 2026-02-17 + ### Breaking Changes - Removed `getSystemInfo()` and `SystemInfo` from package exports, breaking consumers that imported system info APIs from this package ## [12.8.0] - 2026-02-16 + ### Added - Added support for x64 CPU variant selection with `TARGET_VARIANT` environment variable (modern/baseline) during build to optimize for specific ISA levels @@ -408,6 +426,7 @@ - Fixed regex patterns containing literal braces (e.g. `${platform}`) failing with "repetition quantifier expects a valid decimal" by escaping `{`/`}` that don't form valid repetition quantifiers ## [12.5.0] - 2026-02-15 + ### Added - Added `recursive` option to `GlobOptions` to control whether simple patterns match recursively (defaults to true) @@ -418,17 +437,20 @@ - Updated `fileType` filter documentation to clarify that symlinks match file/dir filters based on their target type ## [12.4.0] - 2026-02-14 + ### Added - Exported `sanitizeText` function to strip ANSI codes, remove binary garbage, and normalize line endings in text output ## [12.1.0] - 2026-02-13 + ### Added - Added `cache` option to `glob()`, `grep()`, and `fuzzyFind()` to enable shared filesystem scan caching - Added `invalidateFsScanCache()` function to manually invalidate filesystem scan cache entries ## [11.14.0] - 2026-02-12 + ### Added - Added `PtySession` class for PTY-backed interactive command execution with streaming output @@ -455,6 +477,7 @@ - Native clipboard operations are now best-effort with graceful degradation ## [11.0.0] - 2026-02-05 + ### Removed - Removed legacy type aliases `WasmMatch` and `WasmSearchResult` @@ -466,11 +489,13 @@ - Added separate grep context before/after options in bindings ## [10.2.2] - 2026-02-02 + ### Added - Exported `getWorkProfile` function and `WorkProfile` type for work profiling capabilities ## [10.2.0] - 2026-02-02 + ### Breaking Changes - Replaced `find()` with `glob()` - update imports and function calls @@ -512,6 +537,7 @@ - Added `WorkProfile` type with folded stack format, markdown summary, SVG flamegraph, and sample metrics for profiling results ## [9.8.0] - 2026-02-01 + ### Breaking Changes - Removed `resize()` function; use `PhotonImage.resize()` method instead @@ -581,11 +607,13 @@ - Fixed potential issue where cross-compiled binaries could overwrite platform-specific native builds with incorrect architecture binaries ## [9.6.4] - 2026-02-01 + ### Breaking Changes - Changed callback signature for `find()` and `grep()` streaming callbacks to receive `(error, match)` instead of `(match)` for proper error handling ## [9.6.2] - 2026-02-01 + ### Breaking Changes - Renamed `EllipsisKind` enum to `Ellipsis` @@ -609,6 +637,7 @@ - Removed early return optimization in `truncateToWidth()` when text fits within maxWidth ## [9.6.1] - 2026-02-01 + ### Added - Added `matchesKittySequence` function to match Kitty protocol sequences for codepoint and modifier @@ -618,6 +647,7 @@ - Removed `visibleWidth` function from text utilities ## [9.6.0] - 2026-02-01 + ### Added - Support for cross-compilation via `CARGO_BUILD_TARGET` environment variable @@ -653,4 +683,4 @@ ### Fixed -- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries \ No newline at end of file +- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 7721e767f..f98ceca24 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -136,7 +136,7 @@ export declare class Shell { * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV15_7_6(): void +export declare function __piNativesV15_8_0(): void /** * Apply conservative pre-execution rewrites to a bash command. diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 349620f9c..ce76d3a4f 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -23,7 +23,7 @@ export const PtySession = nativeBindings.PtySession; export const Shell = nativeBindings.Shell; // functions -export const __piNativesV15_7_6 = nativeBindings.__piNativesV15_7_6; +export const __piNativesV15_8_0 = nativeBindings.__piNativesV15_8_0; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; diff --git a/packages/natives/package.json b/packages/natives/package.json index 50c327d97..9d7f07a04 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "15.7.6", + "version": "15.8.0", "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/stats/package.json b/packages/stats/package.json index c788c91c8..f62cfbcd8 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "15.7.6", + "version": "15.8.0", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json index 31875cbb7..c7ab1c2a1 100644 --- a/packages/swarm-extension/package.json +++ b/packages/swarm-extension/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/swarm-extension", - "version": "15.7.6", + "version": "15.8.0", "description": "Swarm orchestration extension for omp", "homepage": "https://omp.sh", "author": "Derek Rynd", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3a268e731..4b33b1c52 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,14 +2,19 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + +### Fixed + +- Deferred eager live scrollback rebuilds on POSIX terminals where xterm ED3 (`CSI 3 J`, erase saved lines) can disturb scrolled-up readers during streaming, while keeping direct user-input and checkpoint rebuilds explicit ([#1682](https://github.com/can1357/oh-my-pi/issues/1682)). +- Fixed TUI shutdown placing the parent shell prompt one row below short rendered content instead of directly on the next line ([#1620](https://github.com/can1357/oh-my-pi/issues/1620)). +- Stopped painting inline color swatches for 4-digit hex runs in Markdown rendering. The `#RGBA` CSS form collides with hashline `#TAG` snapshot tags (4 hex digits, e.g. `#6C5E`), which were sprouting spurious RGB swatches in prose and codespans. Only `#RGB`, `#RRGGBB`, and `#RRGGBBAA` qualify now. + ## [15.7.6] - 2026-06-01 ### Fixed - Fixed native Windows + Windows Terminal freezing the editor on the wrap keystroke, on `/plan`/`/resume`/model-switch/status-line toggles, and on any other offscreen structural mutation until the next prompt submit. The `15.7.5` `#1635` fix routed every viewport-saturating pure-append and structural mutation through `deferredMutation` (a literal no-op) whenever `isNativeViewportAtBottom()` returned `undefined` — which it always does under `WT_SESSION` because the kernel32 probe can't see WT host scrollback. The deferral was only ever meant for the *confirmed-scrolled* case; an unknown viewport now falls back to a non-destructive `viewportRepaint` instead, so the live UI keeps updating without emitting `\x1b[3J` and without yanking a possibly-scrolled reader. Confirmed-scrolled frames (probe returns `false`) still defer. - -### Fixed - - Removed the hard-coded 20-result cap on `@`-prefixed fuzzy file completion in `CombinedAutocompleteProvider.#getFuzzyFileSuggestions`. The dropdown now honors the existing `maxResults: 100` ceiling already configured for `fuzzyFind`, so projects with many files sharing a common stem (e.g. `@controller`, `@test`) surface all relevant matches instead of being silently truncated. ([#1652](https://github.com/can1357/oh-my-pi/issues/1652)) ## [15.7.5] - 2026-06-01 @@ -50,6 +55,7 @@ - Fixed slash-command autocomplete repainting when a Windows Terminal session cannot report native scrollback position; live input renders can now bypass the unknown-viewport deferral without weakening background scrollback protection. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550)) ## [15.6.0] - 2026-05-30 + ### Added - Added autocomplete triggering for internal URL scheme tokens such as `local://` and `skill://` while typing in the editor @@ -96,6 +102,7 @@ - Fixed full TUI redraws clearing terminal scrollback with `CSI 3 J`, preserving manual scrollback inspection while active sessions continue updating. ([#1295](https://github.com/can1357/oh-my-pi/issues/1295)) ## [15.2.3] - 2026-05-22 + ### Added - Added `SettingsList#setItems` to replace the entire settings list with a new items array while automatically clamping selection to a valid index @@ -123,6 +130,7 @@ - Restored the `Key` runtime helper on `@oh-my-pi/pi-tui` to mirror upstream `@mariozechner/pi-tui`'s surface. `Key.enter`, `Key.escape`, `Key.tab`, … return the canonical key-name strings; modifier methods (`Key.ctrl(k)`, `Key.shift(k)`, `Key.ctrlShift(k)`, etc.) build precisely-typed `KeyId` literals like `"ctrl+c"`. Pure runtime convenience for typed key-id construction — plugins built against the upstream package surface that import `Key` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) load again now that the specifier shim remaps them onto this package. ## [15.0.1] - 2026-05-14 + ### Breaking Changes - Increased the minimum required Bun version for the TUI package from >=1.3.7 to >=1.3.14 @@ -156,6 +164,10 @@ - `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`) - Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`) - Plain `@` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`) +- Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text +- Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates +- Changed terminal input handling to process complete cell-size responses without buffering partial input +- Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation ### Fixed @@ -171,20 +183,11 @@ - Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions - Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions - Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input - -### Changed - -- Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text -- Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates -- Changed terminal input handling to process complete cell-size responses without buffering partial input -- Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation - -### Fixed - - Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text - Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event ## [14.9.5] - 2026-05-12 + ### Fixed - Fixed rapidly blinking cursor artifact during task execution by consolidating cursor control sequences into the synchronized output buffer ([#992](https://github.com/can1357/oh-my-pi/issues/992)) @@ -238,6 +241,7 @@ - Autocomplete fuzzy discovery now accepts optional SearchDb instance for faster searches ## [13.16.0] - 2026-03-27 + ### Changed - Updated tab replacement in editor text sanitization to respect configured tab width setting @@ -253,6 +257,7 @@ - Fixed editor consuming user-rebound copy keys, preventing custom keybindings from working in the editor ## [13.14.1] - 2026-03-21 + ### Added - Added Ctrl+_ as an additional default shortcut for undo @@ -273,17 +278,20 @@ - Fixed paste marker expansion to handle special regex replacement tokens ($1, $2, $&, $$, $`, $') literally in pasted content ## [13.11.0] - 2026-03-12 + ### Fixed - Fixed OSC 11 background color detection to correctly handle partial escape sequences that arrive mid-buffer, preventing user input from being swallowed - Fixed race condition where overlapping OSC 11 queries would be incorrectly cancelled by DA1 sentinels from previous queries ## [13.7.5] - 2026-03-04 + ### Changed - Extracted word navigation logic into reusable `moveWordLeft` and `moveWordRight` utility functions for consistent cursor movement across components ## [13.6.2] - 2026-03-03 + ### Fixed - Fixed cursor positioning when content shrinks to empty without clearOnShrink enabled @@ -293,6 +301,7 @@ ### Fixed - Fixed viewport repaint scrollback accounting during resize oscillation to avoid double-scrolling on height shrink and added exact-row scrollback assertions in overlay regression coverage ([#228](https://github.com/can1357/oh-my-pi/issues/228), [#234](https://github.com/can1357/oh-my-pi/issues/234)) + ## [13.5.3] - 2026-03-01 ### Fixed @@ -302,6 +311,7 @@ - Fixed cursor positioning instability when appending content under external cursor relocation by using absolute screen addressing instead of relative cursor movement ## [13.5.2] - 2026-03-01 + ### Breaking Changes - Removed `getMermaidImage` callback from MarkdownTheme; replaced with `getMermaidAscii` that accepts ASCII string instead of image data @@ -312,6 +322,7 @@ - Mermaid diagrams now render as ASCII text instead of terminal graphics protocol images ## [13.5.1] - 2026-03-01 + ### Fixed - Fixed viewport shift handling to prevent stale content when mixed updates remap screen rows @@ -339,6 +350,7 @@ - Fixed stale/duplicated terminal cursor dedup state by synchronizing `#lastCursorSequence` in all render write paths (hard reset, viewport repaint, deleted-lines clear path, append fast path, and differential path). - Fixed scroll overshoot on `stop()` when content fills the viewport by clamping target row movement to valid screen rows. + ## [13.4.0] - 2026-03-01 ### Added @@ -360,6 +372,7 @@ - Restored terminal image protocol override and fallback detection for image rendering, including `PI_FORCE_IMAGE_PROTOCOL` support and Kitty fallback for screen/tmux/ghostty-style TERM environments. ## [13.3.8] - 2026-02-28 + ### Breaking Changes - Changed mermaid hash type from string to bigint in `getMermaidImage` callback and `extractMermaidBlocks` return type @@ -381,6 +394,7 @@ - Fixed stale viewport rows appearing when terminal height increases by triggering full re-render on height changes ## [12.18.0] - 2026-02-21 + ### Fixed - Fixed viewport synchronization issue by clearing scrollback when terminal state becomes desynced during full re-renders @@ -409,18 +423,21 @@ - Fixed incremental stale-row clearing to use erase-below semantics in synchronized output, reducing leftover-line artifacts after shrink operations. ## [12.9.0] - 2026-02-17 + ### Added - Exported `getTerminalId()` function to get a stable identifier for the current terminal, with support for TTY device paths and terminal multiplexers - Exported `getTtyPath()` function to resolve the TTY device path for stdin via POSIX `ttyname(3)` ## [12.5.0] - 2026-02-15 + ### Added - Added `cursorOverride` and `cursorOverrideWidth` properties to customize the end-of-text cursor glyph with ANSI-styled strings - Added `getUseTerminalCursor()` method to query the terminal cursor mode setting ## [11.10.0] - 2026-02-10 + ### Added - Added `hint` property to autocomplete items to display dim ghost text after cursor when item is selected @@ -433,6 +450,7 @@ - Updated editor to render inline hint text as dim ghost text after cursor when autocomplete suggestions are active or provider supplies hints ## [11.8.0] - 2026-02-10 + ### Added - Added Alt+Y keybinding to cycle through kill ring entries (yank-pop) @@ -449,6 +467,7 @@ - Changed undo coalescing in Input component to group consecutive word typing into single undo units ## [11.4.1] - 2026-02-06 + ### Fixed - Fixed terminal scrolling when displaying overlays after rendering large content, preventing hundreds of blank lines from being output @@ -541,6 +560,7 @@ - Fixed handling of private use Unicode codepoints (U+E000 to U+F8FF) in Kitty key decoding to prevent invalid character interpretation ## [9.7.0] - 2026-02-01 + ### Breaking Changes - Removed `Key` helper object from public API; use string literals like `"ctrl+c"` instead of `Key.ctrl("c")` @@ -552,6 +572,7 @@ - Simplified `isKeyRelease()` and `isKeyRepeat()` to use regex pattern matching instead of string inclusion checks ## [9.6.2] - 2026-02-01 + ### Changed - Renamed `EllipsisKind` enum to `Ellipsis` for clearer API naming @@ -565,6 +586,7 @@ - Removed `extractAnsiCode` function from public API ## [9.6.1] - 2026-02-01 + ### Changed - Improved performance of key ID parsing with optimized cache lookup strategy @@ -575,12 +597,14 @@ - Removed `visibleWidth` benchmark file in favor of Kitty sequence benchmarking ## [9.5.0] - 2026-02-01 + ### Changed - Improved fuzzy file search performance by using native implementation instead of spawning external process - Replaced external `fd` binary with native fuzzy path search for `@`-prefixed autocomplete ## [9.4.0] - 2026-01-31 + ### Added - Exported `padding` utility function for creating space-padded strings efficiently @@ -592,59 +616,74 @@ ## [9.2.2] - 2026-01-31 ### Added + - Added setAutocompleteMaxVisible() configuration (3-20 items) - Added image detection to terminal capabilities (containsImage method) - Added stdin monitoring to detect stalled input events and log warnings ### Changed + - Improved blockquote rendering with text wrapping in Markdown component - Restructured terminal capabilities from interface-based to class-based model - Improved table column width calculation with word-aware wrapping - Refactored text utilities to use native WASM implementations for strings >256 chars with JS fast path ### Fixed + - Simplified terminal write error handling to mark terminal as dead on any write failure - Fixed multi-line strings in renderOutputBlock causing width overflow - Fixed slash command autocomplete applying stale completion when typing quickly ### Removed + - Removed TUI layout engine exports from public API (BoxNode, ColumnNode, LayoutNode, etc.) ## [8.12.7] - 2026-01-29 ### Fixed + - Fixed slash command autocomplete applying stale completion when typing quickly ## [8.4.1] - 2026-01-25 ### Added + - Added fuzzy match function for autocomplete suggestions + ## [8.4.0] - 2026-01-25 ### Changed + - Added Ctrl+Backspace as a delete-word-backward keybinding and improved modified backspace matching ### Fixed + - Terminal gracefully handles write failures by marking dead instead of exiting the process - Reserved cursor space for zero padding and corrected end-of-line cursor rendering to prevent wrap glitches - Corrected editor end-of-line cursor rendering assertion to use includes() instead of endsWith() + ## [8.2.0] - 2026-01-24 ### Added + - Added mermaid diagram rendering engine (renderMermaidToPng) with mmdc CLI integration - Added terminal graphics encoding (iTerm2/Kitty) for mermaid diagrams with automatic width scaling - Added mermaid block extraction and deduplication utilities (extractMermaidBlocks) ### Changed + - Updated TypeScript configuration for better publish-time configuration handling with tsconfig.publish.json - Migrated file system operations from synchronous to asynchronous APIs in autocomplete provider for non-blocking I/O - Migrated node module imports from named to namespace imports across all packages for consistency with project guidelines ### Fixed + - Fixed crash when terminal becomes unavailable (EIO errors) by exiting gracefully instead of throwing - Fixed potential errors during emergency terminal restore when terminal is already dead - Fixed autocomplete race condition by tracking request ID to prevent stale suggestion results + ## [6.8.3] - 2026-01-21 + ### Added - Added undo support in the editor via `Ctrl+-` @@ -700,6 +739,7 @@ - Fixed Alt+letter key combinations for better recognition ## [5.3.1] - 2026-01-15 + ### Fixed - Fixed rendering issues on Windows by preventing re-entrant renders @@ -729,27 +769,32 @@ ## [4.7.0] - 2026-01-12 ### Fixed + - Remove trailing space padding from Text, Markdown, and TruncatedText components when no background color is set (fixes copied text including unwanted whitespace) ## [4.6.0] - 2026-01-12 ### Added + - Add fuzzy matching module (`fuzzyMatch`, `fuzzyFilter`) for command autocomplete - Add `getExpandedText()` to editor for expanding paste markers - Add backslash+enter newline fallback for terminals without Kitty protocol ### Fixed + - Remove Kitty protocol query timeout that caused shift+enter delays - Add bracketed paste check to prevent false key release/repeat detection - Rendering optimizations: only re-render changed lines - Refactor input component to use keybindings manager ## [4.4.4] - 2026-01-11 + ### Fixed - Fixed Ctrl+Enter sequences to insert new lines in the editor ## [4.2.1] - 2026-01-11 + ### Changed - Improved file autocomplete to show directory listing when typing `@` with no query, and fall back to prefix matching when fuzzy search returns no results @@ -760,11 +805,13 @@ - Fixed `fd` tool detection to automatically find `fd` or `fdfind` in PATH when not explicitly configured ## [4.1.0] - 2026-01-10 + ### Added - Added persistent prompt history storage support via `setHistoryStorage()` method, allowing history to be saved and restored across sessions ## [4.0.0] - 2026-01-10 + ### Added - `EditorComponent` interface for custom editor implementations @@ -795,6 +842,7 @@ - Fixed text wrapping allowing long whitespace tokens to exceed line width ## [3.20.0] - 2026-01-06 + ### Added - Added `isCapsLock` helper function for detecting Caps Lock key press via Kitty protocol @@ -826,6 +874,7 @@ - Added support for custom spinner frames in the Loader component ## [3.9.1337] - 2026-01-04 + ### Added - Added `setTopBorder()` method to Editor component for displaying custom status content in the top border @@ -838,6 +887,7 @@ - Changed cursor style from block to thin blinking bar (▏) at end of line ## [1.500.0] - 2026-01-03 + ### Added - Added `getText()` method to Text component for retrieving current text content @@ -902,4 +952,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Fixed -- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) \ No newline at end of file +- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) diff --git a/packages/tui/package.json b/packages/tui/package.json index 5b65e6244..f45e2cc1b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "15.7.6", + "version": "15.8.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 19e8a0297..cef891041 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -146,22 +146,24 @@ const DEFAULT_COLOR_SWATCH_GLYPH = "■"; // entities like ☃ and paths like foo#fff) and not trailed by more hex // (so over-long runs never produce a misleading swatch). Length/letter rules // are enforced in classifyHexColor since the alternation can't express "exactly -// 3, 4, 6, or 8". +// 3, 6, or 8". const HEX_COLOR_REGEX = /(? 0) { - const targetRow = this.#previousLines.length; // Line after the last content - const lineDiff = targetRow - this.#hardwareCursorRow; + const targetRow = this.#previousLines.length; + const viewportBottom = this.#viewportTopRow + this.terminal.rows - 1; + const clampedCursorRow = Math.max(this.#viewportTopRow, Math.min(this.#hardwareCursorRow, viewportBottom)); + const moveTargetRow = Math.min(targetRow, viewportBottom); + const lineDiff = moveTargetRow - clampedCursorRow; if (lineDiff > 0) { this.terminal.write(`\x1b[${lineDiff}B`); } else if (lineDiff < 0) { this.terminal.write(`\x1b[${-lineDiff}A`); } - this.terminal.write("\r\n"); + this.terminal.write(targetRow <= viewportBottom ? "\r" : "\r\n"); } this.terminal.showCursor(); @@ -1180,8 +1189,8 @@ export class TUI extends Container { const prevHardwareCursorRow = this.#hardwareCursorRow; const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width; const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height; - const allowUnknownViewportMutation = - this.#allowUnknownViewportMutationOnNextRender || this.#eagerNativeScrollbackRebuild; + const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !terminalHasEagerEraseScrollbackRisk(); + const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender || eagerRebuildAllowed; this.#allowUnknownViewportMutationOnNextRender = false; // 3. Classify intent. @@ -1337,8 +1346,8 @@ export class TUI extends Container { } // POSIX terminals — and Windows Terminal/ConPTY — that cannot report the // viewport position fall through here (`canRebuildNativeScrollbackLive` is - // false). A destructive rebuild emits `\x1b[3J`, which on modern terminals - // resets the viewport to the top of scrollback and yanks a scrolled-up + // false). A destructive rebuild emits `\x1b[3J` (xterm erase saved lines), + // which can clear or reposition native scrollback and yank a scrolled-up // reader (issue #1635), so it is unsafe while the probe is unavailable. // // When the shrunk transcript now fits entirely in the viewport there is no @@ -1660,8 +1669,8 @@ export class TUI extends Container { /** * Live-frame counterpart to {@link #canReplayNativeScrollbackAtCheckpoint}. * Decides whether a destructive native scrollback rebuild - * (`historyRebuild`/`overlayRebuild`, which clear scrollback and snap the - * viewport to the tail) is safe to emit *during ordinary rendering*. POSIX + * (`historyRebuild`/`overlayRebuild`, which clears saved lines and may move + * the native viewport) is safe to emit *during ordinary rendering*. POSIX * terminals cannot report whether the user has scrolled up * (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is * treated as unsafe: defer to a non-destructive viewport repaint, mark @@ -1669,10 +1678,11 @@ export class TUI extends Container { * ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the * editor keystroke has already pinned the terminal to the bottom. Without * this, every offscreen transcript edit while streaming wiped scrollback and - * yanked a scrolled-up reader back down. `allowUnknownViewportMutation` - * (autocomplete/IME) opts directly user-driven frames back into the rebuild. - * Unlike the checkpoint predicate this carries no `process.platform` - * optimism — resize and checkpoint replays keep using that one. + * yanked a scrolled-up reader out of their current context. + * `allowUnknownViewportMutation` (autocomplete/IME) opts directly + * user-driven frames back into the rebuild. Unlike the checkpoint predicate + * this carries no `process.platform` optimism — resize and checkpoint replays + * keep using that one. */ #canRebuildNativeScrollbackLive( nativeViewportAtBottom: boolean | undefined, diff --git a/packages/tui/test/issue-1635-repro.test.ts b/packages/tui/test/issue-1635-repro.test.ts index 39c21cf2e..fd135fefc 100644 --- a/packages/tui/test/issue-1635-repro.test.ts +++ b/packages/tui/test/issue-1635-repro.test.ts @@ -38,8 +38,10 @@ class LineList implements Component { } async function settle(term: VirtualTerminal): Promise { - await new Promise(r => process.nextTick(r)); - await new Promise(r => setTimeout(r, 20)); + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + await Bun.sleep(20); await term.flush(); } diff --git a/packages/tui/test/issue-1682-repro.test.ts b/packages/tui/test/issue-1682-repro.test.ts new file mode 100644 index 000000000..eea3760d4 --- /dev/null +++ b/packages/tui/test/issue-1682-repro.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "bun:test"; +import { type Component, TUI } from "@oh-my-pi/pi-tui"; +import { terminalHasEagerEraseScrollbackRisk } from "@oh-my-pi/pi-tui/terminal"; +import { VirtualTerminal } from "./virtual-terminal"; + +// Regression test for https://github.com/can1357/oh-my-pi/issues/1682 +// +// POSIX hosts cannot report native viewport position, so live render frames see +// `isNativeViewportAtBottom()` as `undefined`. The streaming eager-rebuild mode +// intentionally used that unknown answer as permission to rewrite native +// scrollback, but the rewrite emits xterm ED3 (`CSI 3 J`, erase saved lines). +// On WezTerm/kitty/ghostty/alacritty this can disrupt a reader scrolled into +// native history while assistant/tool output is still streaming. The eager flag +// must therefore defer on those hosts, while ordinary POSIX terminals and +// direct user-input opt-ins keep their existing rebuild behavior. +class LineList implements Component { + #lines: string[]; + + constructor(lines: string[]) { + this.#lines = [...lines]; + } + + invalidate(): void {} + + render(width: number): string[] { + return this.#lines.map(line => line.slice(0, width)); + } + + setLines(lines: string[]): void { + this.#lines = [...lines]; + } +} + +async function settle(term: VirtualTerminal): Promise { + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + await Bun.sleep(20); + await term.flush(); +} + +function capture(term: VirtualTerminal): string[] { + const writes: string[] = []; + const realWrite = term.write.bind(term); + (term as unknown as { write: (s: string) => void }).write = (data: string) => { + writes.push(data); + realWrite(data); + }; + return writes; +} + +function overrideProbe(term: VirtualTerminal, answer: boolean | undefined): void { + (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => answer; +} + +async function withEnvPatch(patch: Record, run: () => T | Promise): Promise { + const saved: Record = {}; + for (const key in patch) { + saved[key] = Bun.env[key]; + const value = patch[key]; + if (value === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = value; + } + } + try { + return await run(); + } finally { + for (const key in saved) { + const value = saved[key]; + if (value === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = value; + } + } + } +} + +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + try { + return await run(); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } +} + +const CLEAR_TERMINAL_RISK_ENV: Record = { + WEZTERM_PANE: undefined, + KITTY_WINDOW_ID: undefined, + GHOSTTY_RESOURCES_DIR: undefined, + ALACRITTY_WINDOW_ID: undefined, + TERM_PROGRAM: undefined, + TMUX: undefined, + STY: undefined, + ZELLIJ: undefined, +}; +const ERASE_SCROLLBACK = /\x1b\[3J/g; + +function eraseScrollbackCount(writes: string[]): number { + return writes.join("").match(ERASE_SCROLLBACK)?.length ?? 0; +} + +describe("issue #1682: terminalHasEagerEraseScrollbackRisk", () => { + it("detects known POSIX terminal identifiers", () => { + expect(terminalHasEagerEraseScrollbackRisk({ WEZTERM_PANE: "1" }, "linux")).toBe(true); + expect(terminalHasEagerEraseScrollbackRisk({ KITTY_WINDOW_ID: "1" }, "linux")).toBe(true); + expect(terminalHasEagerEraseScrollbackRisk({ GHOSTTY_RESOURCES_DIR: "/ghostty" }, "darwin")).toBe(true); + expect(terminalHasEagerEraseScrollbackRisk({ ALACRITTY_WINDOW_ID: "1" }, "darwin")).toBe(true); + expect(terminalHasEagerEraseScrollbackRisk({ TERM_PROGRAM: "ghostty" }, "linux")).toBe(true); + }); + + it("does not trust terminal identifiers on native Windows", () => { + expect(terminalHasEagerEraseScrollbackRisk({ WEZTERM_PANE: "1" }, "win32")).toBe(false); + expect(terminalHasEagerEraseScrollbackRisk({ TERM_PROGRAM: "ghostty" }, "win32")).toBe(false); + }); + + it("leaves unrecognized POSIX terminals on the eager path", () => { + expect(terminalHasEagerEraseScrollbackRisk({}, "linux")).toBe(false); + expect(terminalHasEagerEraseScrollbackRisk({ TERM_PROGRAM: "Apple_Terminal" }, "darwin")).toBe(false); + }); +}); + +describe("issue #1682: TUI eager scrollback rebuild", () => { + it("defers on ED3-risk POSIX terminals and rebuilds at the checkpoint", async () => { + await withPlatform("linux", async () => { + await withEnvPatch({ ...CLEAR_TERMINAL_RISK_ENV, WEZTERM_PANE: "pane-1" }, async () => { + const term = new VirtualTerminal(100, 24); + overrideProbe(term, undefined); + const tui = new TUI(term); + const component = new LineList(Array.from({ length: 80 }, (_value, index) => `init-${index}`)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const writes = capture(term); + tui.setEagerNativeScrollbackRebuild(true); + + component.setLines(Array.from({ length: 20 }, (_value, index) => `shrunk-${index}`)); + tui.requestRender(); + await settle(term); + + expect(eraseScrollbackCount(writes)).toBe(0); + expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true); + await settle(term); + expect(eraseScrollbackCount(writes)).toBe(1); + } finally { + tui.stop(); + } + }); + }); + }); + + it("keeps eager live rebuilds for other POSIX terminals", async () => { + await withPlatform("linux", async () => { + await withEnvPatch(CLEAR_TERMINAL_RISK_ENV, async () => { + const term = new VirtualTerminal(100, 24); + overrideProbe(term, undefined); + const tui = new TUI(term); + const component = new LineList(Array.from({ length: 80 }, (_value, index) => `init-${index}`)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const writes = capture(term); + tui.setEagerNativeScrollbackRebuild(true); + + component.setLines(Array.from({ length: 20 }, (_value, index) => `shrunk-${index}`)); + tui.requestRender(); + await settle(term); + + expect(eraseScrollbackCount(writes)).toBe(1); + expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false); + } finally { + tui.stop(); + } + }); + }); + }); + + it("still honors explicit user-input opt-ins on ED3-risk POSIX terminals", async () => { + await withPlatform("linux", async () => { + await withEnvPatch({ ...CLEAR_TERMINAL_RISK_ENV, WEZTERM_PANE: "pane-1" }, async () => { + const term = new VirtualTerminal(100, 24); + overrideProbe(term, undefined); + const tui = new TUI(term); + const component = new LineList(Array.from({ length: 80 }, (_value, index) => `init-${index}`)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const writes = capture(term); + tui.setEagerNativeScrollbackRebuild(true); + + component.setLines(Array.from({ length: 20 }, (_value, index) => `shrunk-${index}`)); + tui.requestRender(false, { allowUnknownViewportMutation: true }); + await settle(term); + + expect(eraseScrollbackCount(writes)).toBe(1); + expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false); + } finally { + tui.stop(); + } + }); + }); + }); +}); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 02bdfa93a..7bd8df401 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1187,6 +1187,15 @@ describe("Inline color swatches", () => { expect(out.includes(swatchFor("fff"))).toBeTruthy(); }); + it("does not swatch 4-digit hashline #TAG snapshot tags", () => { + // Hashline tags are 4 hex digits with letters (e.g. #6C5E) and would + // otherwise be read as #RGBA colors. Neither prose nor codespans swatch them. + const prose = new Markdown("Re-anchor on #6C5E before editing.", 0, 0, defaultMarkdownTheme).render(80).join(""); + expect(prose.includes("■")).toBe(false); + const code = new Markdown("Tag `#6C5E` stays plain.", 0, 0, defaultMarkdownTheme).render(80).join(""); + expect(code.includes("■")).toBe(false); + }); + it("uses the theme's colorSwatch symbol when provided", () => { const themed = { ...defaultMarkdownTheme, symbols: { ...defaultMarkdownTheme.symbols, colorSwatch: "▢" } }; const out = new Markdown("Accent #C5FFD6.", 0, 0, themed).render(80).join("\n"); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index d7d14c317..a191d1937 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -84,7 +84,9 @@ function rows(prefix: string, count: number): string[] { } async function settle(term: VirtualTerminal): Promise { - await new Promise(resolve => process.nextTick(resolve)); + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; await Bun.sleep(1); await term.flush(); } @@ -102,9 +104,9 @@ function countMatches(lines: string[], pattern: RegExp): number { } async function withEnvPatch(patch: Record, run: () => T | Promise): Promise { - const saved = new Map(); - for (const key of Object.keys(patch)) { - saved.set(key, Bun.env[key]); + const saved: Record = {}; + for (const key in patch) { + saved[key] = Bun.env[key]; const value = patch[key]; if (value === undefined) { delete Bun.env[key]; @@ -115,7 +117,8 @@ async function withEnvPatch(patch: Record, run: ( try { return await run(); } finally { - for (const [key, value] of saved) { + for (const key in saved) { + const value = saved[key]; if (value === undefined) { delete Bun.env[key]; } else { @@ -1815,35 +1818,47 @@ describe("TUI terminal-state regressions", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); try { - await withEnvPatch({ TMUX: undefined, STY: undefined, ZELLIJ: undefined }, async () => { - const term = new UnknownViewportTerminal(40, 5, 200); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("row-", 16)); - tui.addChild(component); + await withEnvPatch( + { + TMUX: undefined, + STY: undefined, + ZELLIJ: undefined, + WEZTERM_PANE: undefined, + KITTY_WINDOW_ID: undefined, + GHOSTTY_RESOURCES_DIR: undefined, + ALACRITTY_WINDOW_ID: undefined, + TERM_PROGRAM: undefined, + }, + async () => { + const term = new UnknownViewportTerminal(40, 5, 200); + const tui = new TUI(term); + const component = new MutableLinesComponent(rows("row-", 16)); + tui.addChild(component); - try { - tui.start(); - await settle(term); - // Default (no active tool) would defer the offscreen edit; confirm the flag flips behavior. - tui.setEagerNativeScrollbackRebuild(true); + try { + tui.start(); + await settle(term); + // Default (no active tool) would defer the offscreen edit; confirm the flag flips behavior. + tui.setEagerNativeScrollbackRebuild(true); - // A streaming tool result re-laying out: an offscreen header changes and the - // block grows past the fold in the same frame. - component.setLines(["HEADER-EDITED", ...rows("row-", 16).slice(1), ...rows("tail-", 4)]); - tui.requestRender(); - await settle(term); + // A streaming tool result re-laying out: an offscreen header changes and the + // block grows past the fold in the same frame. + component.setLines(["HEADER-EDITED", ...rows("row-", 16).slice(1), ...rows("tail-", 4)]); + tui.requestRender(); + await settle(term); - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); - // History was rebuilt at the new content: offscreen edit reflected, no stale copy. - expect(buffer).toContain("HEADER-EDITED"); - expect(buffer).not.toContain("row-0"); - // The grown tail is reachable exactly once — no duplicated rows above the viewport. - expect(buffer.filter(line => line === "tail-3")).toHaveLength(1); - expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false); - } finally { - tui.stop(); - } - }); + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + // History was rebuilt at the new content: offscreen edit reflected, no stale copy. + expect(buffer).toContain("HEADER-EDITED"); + expect(buffer).not.toContain("row-0"); + // The grown tail is reachable exactly once — no duplicated rows above the viewport. + expect(buffer.filter(line => line === "tail-3")).toHaveLength(1); + expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false); + } finally { + tui.stop(); + } + }, + ); } finally { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); } @@ -2228,6 +2243,27 @@ describe("TUI terminal-state regressions", () => { tui.stop(); } }); + + it("leaves the parent shell prompt directly after short content on stop", async () => { + const term = new VirtualTerminal(20, 5); + const tui = new TUI(term); + let stopped = false; + tui.addChild(new MutableLinesComponent(["omp0", "omp1", "omp2"])); + + try { + tui.start(); + await settle(term); + tui.stop(); + stopped = true; + await term.flush(); + term.write("bash$ "); + await term.flush(); + + expect(visible(term)).toEqual(["omp0", "omp1", "omp2", "bash$", ""]); + } finally { + if (!stopped) tui.stop(); + } + }); }); describe("overlay compositing", () => { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index ae22b532c..dfd889265 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -120,8 +120,10 @@ export class VirtualTerminal implements Terminal { /** Wait for TUI's throttled render pipeline to settle (matches the 16ms frame budget). */ async waitForRender(): Promise { - await new Promise(resolve => process.nextTick(resolve)); - await new Promise(resolve => setTimeout(resolve, 20)); + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + await Bun.sleep(20); await this.flush(); } @@ -172,9 +174,9 @@ export class VirtualTerminal implements Terminal { */ async flush(): Promise { // Write an empty string to ensure all previous writes are flushed - return new Promise(resolve => { - this.xterm.write("", () => resolve()); - }); + const done = Promise.withResolvers(); + this.xterm.write("", done.resolve); + return done.promise; } /** diff --git a/packages/utils/package.json b/packages/utils/package.json index e17b7af42..cba65bccf 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "15.7.6", + "version": "15.8.0", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/python/omp-rpc/src/omp_rpc/__init__.py b/python/omp-rpc/src/omp_rpc/__init__.py index 014da5814..3576f18bc 100644 --- a/python/omp-rpc/src/omp_rpc/__init__.py +++ b/python/omp-rpc/src/omp_rpc/__init__.py @@ -16,7 +16,13 @@ from .client import ( ProtocolErrorListener, UiRequestListener, ) -from .host_tools import HostTool, HostToolContext, HostToolResultPayload, HostToolResultValue, host_tool +from .host_tools import ( + HostTool, + HostToolContext, + HostToolResultPayload, + HostToolResultValue, + host_tool, +) from .host_uris import ( HostUri, HostUriContentType, diff --git a/python/omp-rpc/src/omp_rpc/client.py b/python/omp-rpc/src/omp_rpc/client.py index 04e7e5b8f..570c6fb2f 100644 --- a/python/omp-rpc/src/omp_rpc/client.py +++ b/python/omp-rpc/src/omp_rpc/client.py @@ -322,8 +322,12 @@ class RpcClient: self._extra_args = tuple(extra_args) self._startup_timeout = startup_timeout self._request_timeout = request_timeout - self._max_event_history = self._validate_history_limit("max_event_history", max_event_history) - self._max_stderr_chunks = self._validate_history_limit("max_stderr_chunks", max_stderr_chunks) + self._max_event_history = self._validate_history_limit( + "max_event_history", max_event_history + ) + self._max_stderr_chunks = self._validate_history_limit( + "max_stderr_chunks", max_stderr_chunks + ) self._process: subprocess.Popen[str] | None = None self._stdout_thread: threading.Thread | None = None @@ -337,7 +341,9 @@ class RpcClient: self._pending_host_uri_requests: dict[str, _PendingHostUriRequest] = {} self._request_id = 0 self._events = _BoundedHistory[JsonObject](self._max_event_history) - self._async_errors = _BoundedHistory[BaseException](_DEFAULT_ERROR_HISTORY_LIMIT) + self._async_errors = _BoundedHistory[BaseException]( + _DEFAULT_ERROR_HISTORY_LIMIT + ) self._scheduled_agent_runs = 0 self._completed_agent_runs = 0 self._last_schedule_async_error_index = 0 @@ -346,8 +352,12 @@ class RpcClient: self._closed_error: BaseException | None = None self._stopping = False self._ready_received = False - self._protocol_errors = _BoundedHistory[RpcProtocolError](_DEFAULT_ERROR_HISTORY_LIMIT) - self._listener_errors = _BoundedHistory[ListenerErrorEvent](_DEFAULT_ERROR_HISTORY_LIMIT) + self._protocol_errors = _BoundedHistory[RpcProtocolError]( + _DEFAULT_ERROR_HISTORY_LIMIT + ) + self._listener_errors = _BoundedHistory[ListenerErrorEvent]( + _DEFAULT_ERROR_HISTORY_LIMIT + ) self._prompt_lifecycle = _PromptLifecycleCoordinator() self._notification_listeners: list[NotificationListener] = [] @@ -422,15 +432,21 @@ class RpcClient: ) self._process = process - self._stdout_thread = threading.Thread(target=self._read_stdout_loop, name="omp-rpc-stdout", daemon=True) - self._stderr_thread = threading.Thread(target=self._read_stderr_loop, name="omp-rpc-stderr", daemon=True) + self._stdout_thread = threading.Thread( + target=self._read_stdout_loop, name="omp-rpc-stdout", daemon=True + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr_loop, name="omp-rpc-stderr", daemon=True + ) self._stdout_thread.start() self._stderr_thread.start() if not self._ready.wait(self._startup_timeout): stderr = self.stderr self.stop() - raise RpcTimeoutError(f"Timed out waiting for RPC ready signal. Stderr: {stderr}") + raise RpcTimeoutError( + f"Timed out waiting for RPC ready signal. Stderr: {stderr}" + ) if not self._ready_received: error = self._closed_error @@ -439,8 +455,12 @@ class RpcClient: if isinstance(error, RpcError): raise error if error is not None: - raise RpcProcessExitError(f"RPC process stopped before ready: {error}. Stderr: {stderr}") from error - raise RpcTimeoutError(f"Timed out waiting for RPC ready signal. Stderr: {stderr}") + raise RpcProcessExitError( + f"RPC process stopped before ready: {error}. Stderr: {stderr}" + ) from error + raise RpcTimeoutError( + f"Timed out waiting for RPC ready signal. Stderr: {stderr}" + ) if self._custom_tools: self.set_custom_tools(self._custom_tools) @@ -536,31 +556,47 @@ class RpcClient: def on_message_end(self, listener: MessageEndListener) -> Callable[[], None]: return self._add_typed_event_listener("message_end", listener) - def on_tool_execution_start(self, listener: ToolExecutionStartListener) -> Callable[[], None]: + def on_tool_execution_start( + self, listener: ToolExecutionStartListener + ) -> Callable[[], None]: return self._add_typed_event_listener("tool_execution_start", listener) - def on_tool_execution_update(self, listener: ToolExecutionUpdateListener) -> Callable[[], None]: + def on_tool_execution_update( + self, listener: ToolExecutionUpdateListener + ) -> Callable[[], None]: return self._add_typed_event_listener("tool_execution_update", listener) - def on_tool_execution_end(self, listener: ToolExecutionEndListener) -> Callable[[], None]: + def on_tool_execution_end( + self, listener: ToolExecutionEndListener + ) -> Callable[[], None]: return self._add_typed_event_listener("tool_execution_end", listener) - def on_auto_compaction_start(self, listener: AutoCompactionStartListener) -> Callable[[], None]: + def on_auto_compaction_start( + self, listener: AutoCompactionStartListener + ) -> Callable[[], None]: return self._add_typed_event_listener("auto_compaction_start", listener) - def on_auto_compaction_end(self, listener: AutoCompactionEndListener) -> Callable[[], None]: + def on_auto_compaction_end( + self, listener: AutoCompactionEndListener + ) -> Callable[[], None]: return self._add_typed_event_listener("auto_compaction_end", listener) - def on_auto_retry_start(self, listener: AutoRetryStartListener) -> Callable[[], None]: + def on_auto_retry_start( + self, listener: AutoRetryStartListener + ) -> Callable[[], None]: return self._add_typed_event_listener("auto_retry_start", listener) def on_auto_retry_end(self, listener: AutoRetryEndListener) -> Callable[[], None]: return self._add_typed_event_listener("auto_retry_end", listener) - def on_retry_fallback_applied(self, listener: RetryFallbackAppliedListener) -> Callable[[], None]: + def on_retry_fallback_applied( + self, listener: RetryFallbackAppliedListener + ) -> Callable[[], None]: return self._add_typed_event_listener("retry_fallback_applied", listener) - def on_retry_fallback_succeeded(self, listener: RetryFallbackSucceededListener) -> Callable[[], None]: + def on_retry_fallback_succeeded( + self, listener: RetryFallbackSucceededListener + ) -> Callable[[], None]: return self._add_typed_event_listener("retry_fallback_succeeded", listener) def on_ttsr_triggered(self, listener: TtsrTriggeredListener) -> Callable[[], None]: @@ -576,7 +612,9 @@ class RpcClient: self._ui_request_listeners.append(listener) return lambda: self._remove_listener(self._ui_request_listeners, listener) - def on_extension_error(self, listener: ExtensionErrorListener) -> Callable[[], None]: + def on_extension_error( + self, listener: ExtensionErrorListener + ) -> Callable[[], None]: self._extension_error_listeners.append(listener) return lambda: self._remove_listener(self._extension_error_listeners, listener) @@ -588,9 +626,13 @@ class RpcClient: self._listener_error_listeners.append(listener) return lambda: self._remove_listener(self._listener_error_listeners, listener) - def on_unknown_notification(self, listener: UnknownNotificationListener) -> Callable[[], None]: + def on_unknown_notification( + self, listener: UnknownNotificationListener + ) -> Callable[[], None]: self._unknown_notification_listeners.append(listener) - return lambda: self._remove_listener(self._unknown_notification_listeners, listener) + return lambda: self._remove_listener( + self._unknown_notification_listeners, listener + ) def install_headless_ui( self, @@ -651,16 +693,26 @@ class RpcClient: try: return self._ui_requests.get(timeout=timeout) except queue.Empty as exc: - raise RpcTimeoutError("Timed out waiting for an extension UI request") from exc + raise RpcTimeoutError( + "Timed out waiting for an extension UI request" + ) from exc def send_ui_value(self, request_id: str, value: str) -> None: - self._send_notification({"type": "extension_ui_response", "id": request_id, "value": value}) + self._send_notification( + {"type": "extension_ui_response", "id": request_id, "value": value} + ) def send_ui_confirmation(self, request_id: str, confirmed: bool) -> None: - self._send_notification({"type": "extension_ui_response", "id": request_id, "confirmed": confirmed}) + self._send_notification( + {"type": "extension_ui_response", "id": request_id, "confirmed": confirmed} + ) def cancel_ui_request(self, request_id: str, *, timed_out: bool = False) -> None: - payload: JsonObject = {"type": "extension_ui_response", "id": request_id, "cancelled": True} + payload: JsonObject = { + "type": "extension_ui_response", + "id": request_id, + "cancelled": True, + } if timed_out: payload["timedOut"] = True self._send_notification(payload) @@ -724,14 +776,21 @@ class RpcClient: return parse_session_stats(payload) def export_html(self, output_path: str | Path | None = None) -> Path: - payload = self._request("export_html", outputPath=str(output_path) if output_path is not None else None) + payload = self._request( + "export_html", + outputPath=str(output_path) if output_path is not None else None, + ) return Path(str(payload["path"])) def new_session(self, parent_session: str | None = None) -> CancellationResult: - return parse_cancellation_result(self._request("new_session", parentSession=parent_session)) + return parse_cancellation_result( + self._request("new_session", parentSession=parent_session) + ) def switch_session(self, session_path: str | Path) -> CancellationResult: - return parse_cancellation_result(self._request("switch_session", sessionPath=str(session_path))) + return parse_cancellation_result( + self._request("switch_session", sessionPath=str(session_path)) + ) def branch(self, entry_id: str) -> BranchResult: return parse_branch_result(self._request("branch", entryId=entry_id)) @@ -750,7 +809,9 @@ class RpcClient: def get_todos(self) -> tuple[TodoPhase, ...]: return self.get_state().todo_phases - def set_todos(self, todos: Sequence[TodoSeed | TodoPhaseSeed]) -> tuple[TodoPhase, ...]: + def set_todos( + self, todos: Sequence[TodoSeed | TodoPhaseSeed] + ) -> tuple[TodoPhase, ...]: phases = self._normalize_todo_phases(todos) payload = self._request("set_todos", phases=cast(JsonValue, phases)) return parse_todo_phases(payload.get("todoPhases")) @@ -795,7 +856,11 @@ class RpcClient: schemes_payload: list[JsonObject] = [] for uri in self._host_uris: - entry: JsonObject = {"scheme": uri.scheme, "writable": uri.writable, "immutable": uri.immutable} + entry: JsonObject = { + "scheme": uri.scheme, + "writable": uri.writable, + "immutable": uri.immutable, + } if uri.description is not None: entry["description"] = uri.description schemes_payload.append(entry) @@ -824,17 +889,35 @@ class RpcClient: ) self._mark_agent_run_scheduled() - def steer(self, message: str, *, images: Sequence[ImageContent] | None = None) -> None: - self._request("steer", message=message, images=list(images) if images is not None else None) + def steer( + self, message: str, *, images: Sequence[ImageContent] | None = None + ) -> None: + self._request( + "steer", + message=message, + images=list(images) if images is not None else None, + ) - def follow_up(self, message: str, *, images: Sequence[ImageContent] | None = None) -> None: - self._request("follow_up", message=message, images=list(images) if images is not None else None) + def follow_up( + self, message: str, *, images: Sequence[ImageContent] | None = None + ) -> None: + self._request( + "follow_up", + message=message, + images=list(images) if images is not None else None, + ) def abort(self) -> None: self._request("abort") - def abort_and_prompt(self, message: str, *, images: Sequence[ImageContent] | None = None) -> None: - self._request("abort_and_prompt", message=message, images=list(images) if images is not None else None) + def abort_and_prompt( + self, message: str, *, images: Sequence[ImageContent] | None = None + ) -> None: + self._request( + "abort_and_prompt", + message=message, + images=list(images) if images is not None else None, + ) self._mark_agent_run_scheduled() def prompt_and_wait( @@ -851,7 +934,9 @@ class RpcClient: start_index = self._current_event_index() start_async_error_index = self._current_async_error_index() self.prompt(message, images=images, streaming_behavior=streaming_behavior) - events = self._wait_for_agent_end(start_index, start_async_error_index, timeout=timeout) + events = self._wait_for_agent_end( + start_index, start_async_error_index, timeout=timeout + ) return self._build_prompt_turn(events) finally: self._prompt_lifecycle.release(operation) @@ -865,7 +950,9 @@ class RpcClient: return start_index = self._current_event_index() start_async_error_index = self._current_async_error_index() - self._wait_for_agent_end(start_index, start_async_error_index, timeout=timeout) + self._wait_for_agent_end( + start_index, start_async_error_index, timeout=timeout + ) finally: self._prompt_lifecycle.release(operation) @@ -875,7 +962,9 @@ class RpcClient: try: start_index = self._current_event_index() start_async_error_index = self._current_async_error_index() - return self._wait_for_agent_end(start_index, start_async_error_index, timeout=timeout) + return self._wait_for_agent_end( + start_index, start_async_error_index, timeout=timeout + ) finally: self._prompt_lifecycle.release(operation) @@ -894,6 +983,7 @@ class RpcClient: with self._event_condition: self._scheduled_agent_runs += 1 self._last_schedule_async_error_index = self._async_errors.current_index() + def _mark_agent_run_completed(self) -> None: with self._event_condition: self._completed_agent_runs += 1 @@ -905,7 +995,9 @@ class RpcClient: def _check_async_errors(self) -> None: with self._event_condition: - errors = self._async_errors.snapshot_from(self._last_schedule_async_error_index) + errors = self._async_errors.snapshot_from( + self._last_schedule_async_error_index + ) if errors: raise errors[0] @@ -934,7 +1026,9 @@ class RpcClient: events=events, messages=final_messages, assistant_message=assistant_message, - assistant_text=assistant_text(assistant_message) if assistant_message is not None else None, + assistant_text=assistant_text(assistant_message) + if assistant_message is not None + else None, ) def _wait_for_agent_end( @@ -966,13 +1060,20 @@ class RpcClient: raise async_errors[0] event_payloads = self._events.snapshot_from(start_index) - if any(payload.get("type") == "agent_end" for payload in event_payloads): - events = tuple(cast(RpcAgentEvent, parse_notification(payload)) for payload in event_payloads) + if any( + payload.get("type") == "agent_end" for payload in event_payloads + ): + events = tuple( + cast(RpcAgentEvent, parse_notification(payload)) + for payload in event_payloads + ) return events remaining = deadline - time.monotonic() if remaining <= 0: - raise RpcTimeoutError(f"Timed out waiting for agent_end. Stderr: {self.stderr}") + raise RpcTimeoutError( + f"Timed out waiting for agent_end. Stderr: {self.stderr}" + ) self._event_condition.wait(remaining) def _request(self, command_type: str, **payload: JsonValue) -> JsonObject: @@ -985,7 +1086,9 @@ class RpcClient: response_queue: queue.Queue[JsonObject | BaseException] = queue.Queue(maxsize=1) with self._state_lock: - self._pending[request_id] = _PendingRequest(command=command_type, response_queue=response_queue) + self._pending[request_id] = _PendingRequest( + command=command_type, response_queue=response_queue + ) try: self._write_json(process, envelope) @@ -999,13 +1102,18 @@ class RpcClient: except queue.Empty as exc: with self._state_lock: self._pending.pop(request_id, None) - raise RpcTimeoutError(f"Timed out waiting for response to {command_type}. Stderr: {self.stderr}") from exc + raise RpcTimeoutError( + f"Timed out waiting for response to {command_type}. Stderr: {self.stderr}" + ) from exc if isinstance(response, BaseException): raise response if not bool(response.get("success", False)): - raise RpcCommandError(command=str(response.get("command", command_type)), error=str(response.get("error", ""))) + raise RpcCommandError( + command=str(response.get("command", command_type)), + error=str(response.get("error", "")), + ) data = response.get("data") if data is None: @@ -1028,27 +1136,51 @@ class RpcClient: tool_name = payload.get("toolName") tool_call_id = payload.get("toolCallId") raw_arguments = payload.get("arguments") - if not isinstance(request_id, str) or not isinstance(tool_name, str) or not isinstance(tool_call_id, str): + if ( + not isinstance(request_id, str) + or not isinstance(tool_name, str) + or not isinstance(tool_call_id, str) + ): return if not isinstance(raw_arguments, Mapping): self._send_notification( { "type": "host_tool_result", "id": request_id, - "result": {"content": [{"type": "text", "text": "Host tool arguments must be an object"}], "details": {}}, + "result": { + "content": [ + { + "type": "text", + "text": "Host tool arguments must be an object", + } + ], + "details": {}, + }, "isError": True, } ) return - tool = next((candidate for candidate in self._custom_tools if candidate.name == tool_name), None) + tool = next( + ( + candidate + for candidate in self._custom_tools + if candidate.name == tool_name + ), + None, + ) if tool is None: self._send_notification( { "type": "host_tool_result", "id": request_id, "result": { - "content": [{"type": "text", "text": f'Host tool "{tool_name}" is not registered'}], + "content": [ + { + "type": "text", + "text": f'Host tool "{tool_name}" is not registered', + } + ], "details": {}, }, "isError": True, @@ -1066,7 +1198,11 @@ class RpcClient: tool_call_id=tool_call_id, _cancel_event=pending_call.cancel_event, _send_update=lambda result: self._send_notification( - {"type": "host_tool_update", "id": request_id, "partialResult": result} + { + "type": "host_tool_update", + "id": request_id, + "partialResult": result, + } ), ) result = tool.execute(params, context) @@ -1086,14 +1222,19 @@ class RpcClient: { "type": "host_tool_result", "id": request_id, - "result": {"content": [{"type": "text", "text": str(exc)}], "details": {}}, + "result": { + "content": [{"type": "text", "text": str(exc)}], + "details": {}, + }, "isError": True, } ) finally: self._pending_host_tool_calls.pop(request_id, None) - threading.Thread(target=run_tool, name=f"omp-rpc-host-tool:{tool_name}", daemon=True).start() + threading.Thread( + target=run_tool, name=f"omp-rpc-host-tool:{tool_name}", daemon=True + ).start() def _handle_host_tool_cancel(self, payload: JsonObject) -> None: target_id = payload.get("targetId") @@ -1117,10 +1258,16 @@ class RpcClient: request_id = payload.get("id") operation = payload.get("operation") url = payload.get("url") - if not isinstance(request_id, str) or not isinstance(operation, str) or not isinstance(url, str): + if ( + not isinstance(request_id, str) + or not isinstance(operation, str) + or not isinstance(url, str) + ): return if operation not in ("read", "write"): - self._send_host_uri_error(request_id, f"Unsupported host URI operation: {operation}") + self._send_host_uri_error( + request_id, f"Unsupported host URI operation: {operation}" + ) return try: @@ -1131,14 +1278,20 @@ class RpcClient: self._send_host_uri_error(request_id, f"Could not parse host URI: {url}") return scheme = (parsed.scheme or "").lower() - uri = next((candidate for candidate in self._host_uris if candidate.scheme == scheme), None) + uri = next( + (candidate for candidate in self._host_uris if candidate.scheme == scheme), + None, + ) if uri is None: - self._send_host_uri_error(request_id, f'Host URI scheme "{scheme}://" is not registered') + self._send_host_uri_error( + request_id, f'Host URI scheme "{scheme}://" is not registered' + ) return if operation == "write" and uri.write is None: self._send_host_uri_error( - request_id, f'Host URI scheme "{scheme}://" was not registered with a write handler' + request_id, + f'Host URI scheme "{scheme}://" was not registered with a write handler', ) return @@ -1147,7 +1300,11 @@ class RpcClient: def run() -> None: try: - context = HostUriContext(url=url, operation=cast(Any, operation), _cancel_event=pending.cancel_event) + context = HostUriContext( + url=url, + operation=cast(Any, operation), + _cancel_event=pending.cancel_event, + ) if operation == "read": value = uri.read(url, context) if pending.cancel_event.is_set(): @@ -1167,7 +1324,9 @@ class RpcClient: uri.write(url, content, context) if pending.cancel_event.is_set(): return - self._send_notification({"type": "host_uri_result", "id": request_id}) + self._send_notification( + {"type": "host_uri_result", "id": request_id} + ) except Exception as exc: if pending.cancel_event.is_set(): return @@ -1175,7 +1334,9 @@ class RpcClient: finally: self._pending_host_uri_requests.pop(request_id, None) - threading.Thread(target=run, name=f"omp-rpc-host-uri:{scheme}:{operation}", daemon=True).start() + threading.Thread( + target=run, name=f"omp-rpc-host-uri:{scheme}:{operation}", daemon=True + ).start() def _handle_host_uri_cancel(self, payload: JsonObject) -> None: target_id = payload.get("targetId") @@ -1185,14 +1346,18 @@ class RpcClient: if pending is not None: pending.cancel_event.set() - def _add_typed_event_listener(self, event_type: str, listener: TEventListener) -> Callable[[], None]: + def _add_typed_event_listener( + self, event_type: str, listener: TEventListener + ) -> Callable[[], None]: listeners = self._typed_event_listeners.setdefault(event_type, []) typed_listener = cast(AgentEventListener, listener) listeners.append(typed_listener) return lambda: self._remove_listener(listeners, typed_listener) @staticmethod - def _normalize_todo_phases(todos: Sequence[TodoSeed | TodoPhaseSeed]) -> list[JsonObject]: + def _normalize_todo_phases( + todos: Sequence[TodoSeed | TodoPhaseSeed], + ) -> list[JsonObject]: if len(todos) == 0: return [] @@ -1206,7 +1371,11 @@ class RpcClient: def normalize_todo_item(seed: TodoSeed) -> JsonObject: if isinstance(seed, str): - return {"id": next_task(), "content": seed, "status": cast(JsonValue, "pending")} + return { + "id": next_task(), + "content": seed, + "status": cast(JsonValue, "pending"), + } if isinstance(seed, TodoItem): if seed.status not in _TODO_STATUS_VALUES: @@ -1234,7 +1403,9 @@ class RpcClient: else: status = "pending" return { - "id": str(raw_id) if isinstance(raw_id, str) and raw_id else next_task(), + "id": str(raw_id) + if isinstance(raw_id, str) and raw_id + else next_task(), "content": content, "status": cast(JsonValue, status), "notes": raw_notes if isinstance(raw_notes, str) else None, @@ -1259,11 +1430,19 @@ class RpcClient: raise RpcError("Todo phases must provide a non-empty 'name' value") phase_id_value = seed.get("id") raw_tasks = seed.get("tasks") or () - if not isinstance(raw_tasks, Sequence) or isinstance(raw_tasks, (str, bytes)): + if not isinstance(raw_tasks, Sequence) or isinstance( + raw_tasks, (str, bytes) + ): raise RpcError("Todo phase 'tasks' must be a sequence") - phase_id = str(phase_id_value) if isinstance(phase_id_value, str) and phase_id_value else f"phase-{index}" + phase_id = ( + str(phase_id_value) + if isinstance(phase_id_value, str) and phase_id_value + else f"phase-{index}" + ) name = raw_name - tasks = [normalize_todo_item(cast(TodoSeed, task)) for task in raw_tasks] + tasks = [ + normalize_todo_item(cast(TodoSeed, task)) for task in raw_tasks + ] return {"id": phase_id, "name": name, "tasks": tasks} @@ -1271,11 +1450,19 @@ class RpcClient: phases: list[JsonObject] = [] for index, seed in enumerate(todos, start=1): if not is_phase_seed(seed): - raise RpcError("Cannot mix flat todo items with todo phases in one set_todos() call") + raise RpcError( + "Cannot mix flat todo items with todo phases in one set_todos() call" + ) phases.append(normalize_phase(cast(TodoPhaseSeed, seed), index)) return phases - return [{"id": "phase-1", "name": "Todos", "tasks": [normalize_todo_item(cast(TodoSeed, todo)) for todo in todos]}] + return [ + { + "id": "phase-1", + "name": "Todos", + "tasks": [normalize_todo_item(cast(TodoSeed, todo)) for todo in todos], + } + ] def _build_command(self) -> tuple[str, ...]: if self._command is not None: @@ -1305,7 +1492,9 @@ class RpcClient: command.append("--no-skills") if self._no_rules: command.append("--no-rules") - emit_no_title = self._no_title if self._no_title is not None else self._rpc_defaults + emit_no_title = ( + self._no_title if self._no_title is not None else self._rpc_defaults + ) if emit_no_title: command.append("--no-title") command.extend(self._extra_args) @@ -1330,7 +1519,9 @@ class RpcClient: process.stdin.write("\n") process.stdin.flush() except (BrokenPipeError, OSError) as exc: - raise RpcProcessExitError(f"Failed to write RPC command: {exc}") from exc + raise RpcProcessExitError( + f"Failed to write RPC command: {exc}" + ) from exc def _read_stdout_loop(self) -> None: process = self._process @@ -1382,7 +1573,12 @@ class RpcClient: if isinstance(notification, ReadyEvent): self._ready_received = True self._ready.set() - self._dispatch_listeners("ready", listener_notification.type, self._ready_listeners, listener_notification) + self._dispatch_listeners( + "ready", + listener_notification.type, + self._ready_listeners, + listener_notification, + ) continue if isinstance(notification, ExtensionUiRequest): @@ -1417,9 +1613,14 @@ class RpcClient: self._append_event(payload) if listener_event.type == "agent_end": self._mark_agent_run_completed() - self._dispatch_listeners("event", listener_event.type, self._event_listeners, listener_event) self._dispatch_listeners( - "typed_event", listener_event.type, self._typed_event_listeners.get(listener_event.type, []), listener_event + "event", listener_event.type, self._event_listeners, listener_event + ) + self._dispatch_listeners( + "typed_event", + listener_event.type, + self._typed_event_listeners.get(listener_event.type, []), + listener_event, ) except Exception as exc: self._mark_closed(exc) @@ -1430,9 +1631,17 @@ class RpcClient: try: exit_code = process.wait(timeout=1.0) except subprocess.TimeoutExpired: - self._mark_closed(RpcProcessExitError("RPC process stdout closed before the process exited")) + self._mark_closed( + RpcProcessExitError( + "RPC process stdout closed before the process exited" + ) + ) return - self._mark_closed(RpcProcessExitError(f"RPC process exited with code {exit_code}. Stderr: {self.stderr}")) + self._mark_closed( + RpcProcessExitError( + f"RPC process exited with code {exit_code}. Stderr: {self.stderr}" + ) + ) def _read_stderr_loop(self) -> None: process = self._process @@ -1478,8 +1687,13 @@ class RpcClient: if protocol_error is None: return - if protocol_error.command in _ASYNC_COMMANDS and protocol_error.remote_error is not None: - self._append_async_error(RpcCommandError(protocol_error.command, protocol_error.remote_error)) + if ( + protocol_error.command in _ASYNC_COMMANDS + and protocol_error.remote_error is not None + ): + self._append_async_error( + RpcCommandError(protocol_error.command, protocol_error.remote_error) + ) self._mark_agent_run_completed() self._record_protocol_error(protocol_error) @@ -1493,7 +1707,11 @@ class RpcClient: return False with self._state_lock: - matching_ids = [request_id for request_id, pending in self._pending.items() if pending.command == command] + matching_ids = [ + request_id + for request_id, pending in self._pending.items() + if pending.command == command + ] target_id: str | None = None if len(matching_ids) == 1: target_id = matching_ids[0] @@ -1528,7 +1746,9 @@ class RpcClient: def _record_protocol_error(self, error: RpcProtocolError) -> None: with self._state_lock: self._protocol_errors.append(error) - self._dispatch_listeners("protocol_error", error.command, self._protocol_error_listeners, error) + self._dispatch_listeners( + "protocol_error", error.command, self._protocol_error_listeners, error + ) def _record_listener_error(self, event: ListenerErrorEvent) -> None: with self._state_lock: diff --git a/python/omp-rpc/src/omp_rpc/host_uris.py b/python/omp-rpc/src/omp_rpc/host_uris.py index 87b470be5..be320f9eb 100644 --- a/python/omp-rpc/src/omp_rpc/host_uris.py +++ b/python/omp-rpc/src/omp_rpc/host_uris.py @@ -9,7 +9,9 @@ from .protocol import JsonObject TPayload = TypeVar("TPayload") -HostUriContentType: TypeAlias = Literal["text/markdown", "application/json", "text/plain"] +HostUriContentType: TypeAlias = Literal[ + "text/markdown", "application/json", "text/plain" +] class HostUriReadResult(TypedDict, total=False): @@ -99,7 +101,9 @@ def normalize_read_result(value: HostUriReadValue) -> JsonObject: if isinstance(value, str): return {"content": value} if not isinstance(value, dict): - raise TypeError("Host URI read handlers must return a string or a HostUriReadResult mapping") + raise TypeError( + "Host URI read handlers must return a string or a HostUriReadResult mapping" + ) payload: JsonObject = {} if "content" not in value: diff --git a/python/omp-rpc/src/omp_rpc/protocol.py b/python/omp-rpc/src/omp_rpc/protocol.py index 0d9b5e5aa..f29182e8c 100644 --- a/python/omp-rpc/src/omp_rpc/protocol.py +++ b/python/omp-rpc/src/omp_rpc/protocol.py @@ -31,24 +31,38 @@ ExtensionUiMethod: TypeAlias = Literal[ "setTitle", "set_editor_text", ] -InteractiveExtensionUiMethod: TypeAlias = Literal["select", "confirm", "input", "editor"] -PassiveExtensionUiMethod: TypeAlias = Literal["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"] +InteractiveExtensionUiMethod: TypeAlias = Literal[ + "select", "confirm", "input", "editor" +] +PassiveExtensionUiMethod: TypeAlias = Literal[ + "notify", "setStatus", "setWidget", "setTitle", "set_editor_text" +] ValueExtensionUiMethod: TypeAlias = Literal["select", "input", "editor"] PASSIVE_EXTENSION_UI_METHODS: Final[frozenset[PassiveExtensionUiMethod]] = frozenset( {"notify", "setStatus", "setWidget", "setTitle", "set_editor_text"} ) -INTERACTIVE_EXTENSION_UI_METHODS: Final[frozenset[InteractiveExtensionUiMethod]] = frozenset( - {"select", "confirm", "input", "editor"} +INTERACTIVE_EXTENSION_UI_METHODS: Final[frozenset[InteractiveExtensionUiMethod]] = ( + frozenset({"select", "confirm", "input", "editor"}) +) +VALUE_EXTENSION_UI_METHODS: Final[frozenset[ValueExtensionUiMethod]] = frozenset( + {"select", "input", "editor"} +) +_THINKING_LEVEL_VALUES: Final[frozenset[str]] = frozenset( + {"off", "minimal", "low", "medium", "high", "xhigh"} ) -VALUE_EXTENSION_UI_METHODS: Final[frozenset[ValueExtensionUiMethod]] = frozenset({"select", "input", "editor"}) -_THINKING_LEVEL_VALUES: Final[frozenset[str]] = frozenset({"off", "minimal", "low", "medium", "high", "xhigh"}) _STEERING_MODE_VALUES: Final[frozenset[str]] = frozenset({"all", "one-at-a-time"}) _INTERRUPT_MODE_VALUES: Final[frozenset[str]] = frozenset({"immediate", "wait"}) -_STOP_REASON_VALUES: Final[frozenset[str]] = frozenset({"stop", "length", "toolUse", "error", "aborted"}) +_STOP_REASON_VALUES: Final[frozenset[str]] = frozenset( + {"stop", "length", "toolUse", "error", "aborted"} +) _NOTIFY_TYPE_VALUES: Final[frozenset[str]] = frozenset({"info", "warning", "error"}) -_WIDGET_PLACEMENT_VALUES: Final[frozenset[str]] = frozenset({"aboveEditor", "belowEditor"}) -_TODO_STATUS_VALUES: Final[frozenset[str]] = frozenset({"pending", "in_progress", "completed", "abandoned"}) +_WIDGET_PLACEMENT_VALUES: Final[frozenset[str]] = frozenset( + {"aboveEditor", "belowEditor"} +) +_TODO_STATUS_VALUES: Final[frozenset[str]] = frozenset( + {"pending", "in_progress", "completed", "abandoned"} +) _EXTENSION_UI_METHOD_VALUES: Final[frozenset[str]] = frozenset( { "select", @@ -94,10 +108,16 @@ _ASSISTANT_MESSAGE_EVENT_TYPE_VALUES: Final[frozenset[str]] = frozenset( "error", } ) -_ASSISTANT_DONE_REASON_VALUES: Final[frozenset[str]] = frozenset({"stop", "length", "toolUse"}) +_ASSISTANT_DONE_REASON_VALUES: Final[frozenset[str]] = frozenset( + {"stop", "length", "toolUse"} +) _ASSISTANT_ERROR_REASON_VALUES: Final[frozenset[str]] = frozenset({"aborted", "error"}) -_AUTO_COMPACTION_REASON_VALUES: Final[frozenset[str]] = frozenset({"threshold", "overflow", "idle"}) -_AUTO_COMPACTION_ACTION_VALUES: Final[frozenset[str]] = frozenset({"context-full", "handoff"}) +_AUTO_COMPACTION_REASON_VALUES: Final[frozenset[str]] = frozenset( + {"threshold", "overflow", "idle"} +) +_AUTO_COMPACTION_ACTION_VALUES: Final[frozenset[str]] = frozenset( + {"context-full", "handoff"} +) def _clone_json_value(value: object, *, field: str) -> JsonValue: @@ -142,7 +162,9 @@ def _require_literal(value: object, allowed: frozenset[str], *, field: str) -> s return value -def _optional_literal(value: object, allowed: frozenset[str], *, field: str) -> str | None: +def _optional_literal( + value: object, allowed: frozenset[str], *, field: str +) -> str | None: if value is None: return None return _require_literal(value, allowed, field=field) @@ -220,7 +242,9 @@ def _tuple_of_strings(values: object, *, field: str) -> tuple[str, ...] | None: def _parse_agent_message(payload: JsonObject, *, field: str) -> AgentMessage: - _require_literal(payload.get("role"), _AGENT_MESSAGE_ROLE_VALUES, field=f"{field}.role") + _require_literal( + payload.get("role"), _AGENT_MESSAGE_ROLE_VALUES, field=f"{field}.role" + ) return cast(AgentMessage, _clone_json_object(payload, field=field)) @@ -246,7 +270,12 @@ def parse_agent_messages(payload: JsonValue | None) -> tuple[AgentMessage, ...]: messages: list[AgentMessage] = [] for index, item in enumerate(payload): - messages.append(_parse_agent_message(_clone_json_object(item, field=f"messages[{index}]"), field=f"messages[{index}]")) + messages.append( + _parse_agent_message( + _clone_json_object(item, field=f"messages[{index}]"), + field=f"messages[{index}]", + ) + ) return tuple(messages) @@ -259,13 +288,17 @@ def parse_assistant_message_event(payload: JsonObject) -> AssistantMessageEvent: if event_type == "start": return AssistantMessageStartEvent( partial=_parse_assistant_message( - _clone_json_object(payload.get("partial"), field="assistantMessageEvent.partial"), + _clone_json_object( + payload.get("partial"), field="assistantMessageEvent.partial" + ), field="assistantMessageEvent.partial", ) ) if event_type in {"text_start", "thinking_start", "toolcall_start"}: partial = _parse_assistant_message( - _clone_json_object(payload.get("partial"), field="assistantMessageEvent.partial"), + _clone_json_object( + payload.get("partial"), field="assistantMessageEvent.partial" + ), field="assistantMessageEvent.partial", ) content_index = _optional_int(payload, "contentIndex") @@ -274,11 +307,15 @@ def parse_assistant_message_event(payload: JsonObject) -> AssistantMessageEvent: if event_type == "text_start": return AssistantTextStartEvent(contentIndex=content_index, partial=partial) if event_type == "thinking_start": - return AssistantThinkingStartEvent(contentIndex=content_index, partial=partial) + return AssistantThinkingStartEvent( + contentIndex=content_index, partial=partial + ) return AssistantToolCallStartEvent(contentIndex=content_index, partial=partial) if event_type in {"text_delta", "thinking_delta", "toolcall_delta"}: partial = _parse_assistant_message( - _clone_json_object(payload.get("partial"), field="assistantMessageEvent.partial"), + _clone_json_object( + payload.get("partial"), field="assistantMessageEvent.partial" + ), field="assistantMessageEvent.partial", ) content_index = _optional_int(payload, "contentIndex") @@ -288,13 +325,21 @@ def parse_assistant_message_event(payload: JsonObject) -> AssistantMessageEvent: if delta is None: raise ValueError("assistantMessageEvent.delta must be a string") if event_type == "text_delta": - return AssistantTextDeltaEvent(contentIndex=content_index, delta=delta, partial=partial) + return AssistantTextDeltaEvent( + contentIndex=content_index, delta=delta, partial=partial + ) if event_type == "thinking_delta": - return AssistantThinkingDeltaEvent(contentIndex=content_index, delta=delta, partial=partial) - return AssistantToolCallDeltaEvent(contentIndex=content_index, delta=delta, partial=partial) + return AssistantThinkingDeltaEvent( + contentIndex=content_index, delta=delta, partial=partial + ) + return AssistantToolCallDeltaEvent( + contentIndex=content_index, delta=delta, partial=partial + ) if event_type in {"text_end", "thinking_end"}: partial = _parse_assistant_message( - _clone_json_object(payload.get("partial"), field="assistantMessageEvent.partial"), + _clone_json_object( + payload.get("partial"), field="assistantMessageEvent.partial" + ), field="assistantMessageEvent.partial", ) content_index = _optional_int(payload, "contentIndex") @@ -304,36 +349,60 @@ def parse_assistant_message_event(payload: JsonObject) -> AssistantMessageEvent: if content is None: raise ValueError("assistantMessageEvent.content must be a string") if event_type == "text_end": - return AssistantTextEndEvent(contentIndex=content_index, content=content, partial=partial) - return AssistantThinkingEndEvent(contentIndex=content_index, content=content, partial=partial) + return AssistantTextEndEvent( + contentIndex=content_index, content=content, partial=partial + ) + return AssistantThinkingEndEvent( + contentIndex=content_index, content=content, partial=partial + ) if event_type == "toolcall_end": partial = _parse_assistant_message( - _clone_json_object(payload.get("partial"), field="assistantMessageEvent.partial"), + _clone_json_object( + payload.get("partial"), field="assistantMessageEvent.partial" + ), field="assistantMessageEvent.partial", ) content_index = _optional_int(payload, "contentIndex") if content_index is None: raise ValueError("assistantMessageEvent.contentIndex must be an integer") - tool_call = _clone_json_object(payload.get("toolCall"), field="assistantMessageEvent.toolCall") - return AssistantToolCallEndEvent(contentIndex=content_index, toolCall=cast(ToolCall, tool_call), partial=partial) + tool_call = _clone_json_object( + payload.get("toolCall"), field="assistantMessageEvent.toolCall" + ) + return AssistantToolCallEndEvent( + contentIndex=content_index, + toolCall=cast(ToolCall, tool_call), + partial=partial, + ) if event_type == "done": return AssistantDoneEvent( reason=cast( Literal["stop", "length", "toolUse"], - _require_literal(payload.get("reason"), _ASSISTANT_DONE_REASON_VALUES, field="assistantMessageEvent.reason"), + _require_literal( + payload.get("reason"), + _ASSISTANT_DONE_REASON_VALUES, + field="assistantMessageEvent.reason", + ), ), message=_parse_assistant_message( - _clone_json_object(payload.get("message"), field="assistantMessageEvent.message"), + _clone_json_object( + payload.get("message"), field="assistantMessageEvent.message" + ), field="assistantMessageEvent.message", ), ) return AssistantErrorEvent( reason=cast( Literal["aborted", "error"], - _require_literal(payload.get("reason"), _ASSISTANT_ERROR_REASON_VALUES, field="assistantMessageEvent.reason"), + _require_literal( + payload.get("reason"), + _ASSISTANT_ERROR_REASON_VALUES, + field="assistantMessageEvent.reason", + ), ), error=_parse_assistant_message( - _clone_json_object(payload.get("error"), field="assistantMessageEvent.error"), + _clone_json_object( + payload.get("error"), field="assistantMessageEvent.error" + ), field="assistantMessageEvent.error", ), ) @@ -984,12 +1053,22 @@ RpcAgentEvent: TypeAlias = ( | TodoAutoClearEvent ) -RpcNotification: TypeAlias = ReadyEvent | ExtensionUiRequest | ExtensionError | RpcAgentEvent | UnknownNotification +RpcNotification: TypeAlias = ( + ReadyEvent + | ExtensionUiRequest + | ExtensionError + | RpcAgentEvent + | UnknownNotification +) def image_from_path(path: str | Path, mime_type: str | None = None) -> ImageContent: file_path = Path(path) - resolved_mime_type = mime_type or mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + resolved_mime_type = ( + mime_type + or mimetypes.guess_type(file_path.name)[0] + or "application/octet-stream" + ) return { "type": "image", "mimeType": resolved_mime_type, @@ -997,9 +1076,18 @@ def image_from_path(path: str | Path, mime_type: str | None = None) -> ImageCont } -def message_text(message: AgentMessage, *, include_thinking: bool = False) -> str | None: +def message_text( + message: AgentMessage, *, include_thinking: bool = False +) -> str | None: role = message.get("role") - if role not in {"user", "developer", "assistant", "toolResult", "custom", "hookMessage"}: + if role not in { + "user", + "developer", + "assistant", + "toolResult", + "custom", + "hookMessage", + }: return None content = message.get("content") @@ -1015,7 +1103,11 @@ def message_text(message: AgentMessage, *, include_thinking: bool = False) -> st block_type = block.get("type") if block_type == "text" and isinstance(block.get("text"), str): fragments.append(cast(str, block["text"])) - elif include_thinking and block_type == "thinking" and isinstance(block.get("thinking"), str): + elif ( + include_thinking + and block_type == "thinking" + and isinstance(block.get("thinking"), str) + ): fragments.append(cast(str, block["thinking"])) return "".join(fragments) or None @@ -1024,7 +1116,9 @@ def message_text_with_thinking(message: AgentMessage) -> str | None: return message_text(message, include_thinking=True) -def assistant_text(message: AgentMessage, *, include_thinking: bool = False) -> str | None: +def assistant_text( + message: AgentMessage, *, include_thinking: bool = False +) -> str | None: if message.get("role") != "assistant": return None return message_text(message, include_thinking=include_thinking) @@ -1048,7 +1142,8 @@ def parse_model_info(payload: JsonObject | None) -> ModelInfo | None: provider=_require_str(payload, "provider"), base_url=_require_str(payload, "baseUrl"), reasoning=bool(payload.get("reasoning", False)), - input_modalities=_tuple_of_strings(payload.get("input"), field="model.input") or (), + input_modalities=_tuple_of_strings(payload.get("input"), field="model.input") + or (), cost=ModelCost( input=float(cost_payload.get("input", 0.0)), output=float(cost_payload.get("output", 0.0)), @@ -1057,22 +1152,39 @@ def parse_model_info(payload: JsonObject | None) -> ModelInfo | None: ), context_window=int(payload.get("contextWindow", 0)), max_tokens=int(payload.get("maxTokens", 0)), - headers=cast(dict[str, str] | None, _optional_json_object(headers_payload, field="model.headers")), - premium_multiplier=float(payload["premiumMultiplier"]) if "premiumMultiplier" in payload else None, - prefer_websockets=bool(payload["preferWebsockets"]) if "preferWebsockets" in payload else None, + headers=cast( + dict[str, str] | None, + _optional_json_object(headers_payload, field="model.headers"), + ), + premium_multiplier=float(payload["premiumMultiplier"]) + if "premiumMultiplier" in payload + else None, + prefer_websockets=bool(payload["preferWebsockets"]) + if "preferWebsockets" in payload + else None, context_promotion_target=( - str(payload["contextPromotionTarget"]) if "contextPromotionTarget" in payload else None + str(payload["contextPromotionTarget"]) + if "contextPromotionTarget" in payload + else None ), priority=int(payload["priority"]) if "priority" in payload else None, thinking=( ThinkingConfig( min_level=cast( ThinkingLevel, - _require_literal(thinking_payload.get("minLevel"), _THINKING_LEVEL_VALUES, field="model.thinking.minLevel"), + _require_literal( + thinking_payload.get("minLevel"), + _THINKING_LEVEL_VALUES, + field="model.thinking.minLevel", + ), ), max_level=cast( ThinkingLevel, - _require_literal(thinking_payload.get("maxLevel"), _THINKING_LEVEL_VALUES, field="model.thinking.maxLevel"), + _require_literal( + thinking_payload.get("maxLevel"), + _THINKING_LEVEL_VALUES, + field="model.thinking.maxLevel", + ), ), mode=_require_str(cast(JsonObject, thinking_payload), "mode"), ) @@ -1087,7 +1199,9 @@ def parse_tool_descriptor(payload: JsonObject) -> ToolDescriptor: return ToolDescriptor( name=_require_str(payload, "name"), description=_require_str(payload, "description"), - parameters=_clone_json_value(payload.get("parameters"), field="tool.parameters"), + parameters=_clone_json_value( + payload.get("parameters"), field="tool.parameters" + ), ) @@ -1097,7 +1211,11 @@ def parse_todo_item(payload: JsonObject) -> TodoItem: content=_require_str(payload, "content"), status=cast( TodoStatus, - _require_literal(payload.get("status", "pending"), _TODO_STATUS_VALUES, field="todo.status"), + _require_literal( + payload.get("status", "pending"), + _TODO_STATUS_VALUES, + field="todo.status", + ), ), notes=_optional_str(payload, "notes"), details=_optional_str(payload, "details"), @@ -1111,7 +1229,10 @@ def parse_todo_phase(payload: JsonObject) -> TodoPhase: else: if not isinstance(raw_tasks, list): raise ValueError("tasks must be a list") - tasks = tuple(parse_todo_item(_clone_json_object(item, field="tasks[]")) for item in raw_tasks) + tasks = tuple( + parse_todo_item(_clone_json_object(item, field="tasks[]")) + for item in raw_tasks + ) return TodoPhase( id=str(payload.get("id", "")), name=_require_str(payload, "name"), @@ -1127,27 +1248,44 @@ def parse_todo_phases(payload: JsonValue | None) -> tuple[TodoPhase, ...]: def parse_session_state(payload: JsonObject) -> SessionState: dump_tools = tuple( - parse_tool_descriptor(_clone_json_object(item, field="dumpTools[]")) for item in cast(list[Any], payload.get("dumpTools") or []) + parse_tool_descriptor(_clone_json_object(item, field="dumpTools[]")) + for item in cast(list[Any], payload.get("dumpTools") or []) ) return SessionState( model=parse_model_info(cast(JsonObject | None, payload.get("model"))), thinking_level=cast( ThinkingLevel | None, - _optional_literal(payload.get("thinkingLevel"), _THINKING_LEVEL_VALUES, field="thinkingLevel"), + _optional_literal( + payload.get("thinkingLevel"), + _THINKING_LEVEL_VALUES, + field="thinkingLevel", + ), ), is_streaming=bool(payload.get("isStreaming", False)), is_compacting=bool(payload.get("isCompacting", False)), steering_mode=cast( SteeringMode, - _require_literal(payload.get("steeringMode", "one-at-a-time"), _STEERING_MODE_VALUES, field="steeringMode"), + _require_literal( + payload.get("steeringMode", "one-at-a-time"), + _STEERING_MODE_VALUES, + field="steeringMode", + ), ), follow_up_mode=cast( SteeringMode, - _require_literal(payload.get("followUpMode", "one-at-a-time"), _STEERING_MODE_VALUES, field="followUpMode"), + _require_literal( + payload.get("followUpMode", "one-at-a-time"), + _STEERING_MODE_VALUES, + field="followUpMode", + ), ), interrupt_mode=cast( InterruptMode, - _require_literal(payload.get("interruptMode", "immediate"), _INTERRUPT_MODE_VALUES, field="interruptMode"), + _require_literal( + payload.get("interruptMode", "immediate"), + _INTERRUPT_MODE_VALUES, + field="interruptMode", + ), ), session_file=_optional_str(payload, "sessionFile"), session_id=_require_str(payload, "sessionId"), @@ -1155,7 +1293,9 @@ def parse_session_state(payload: JsonObject) -> SessionState: auto_compaction_enabled=bool(payload.get("autoCompactionEnabled", False)), message_count=int(payload.get("messageCount", 0)), queued_message_count=int(payload.get("queuedMessageCount", 0)), - todo_phases=parse_todo_phases(cast(JsonValue | None, payload.get("todoPhases"))), + todo_phases=parse_todo_phases( + cast(JsonValue | None, payload.get("todoPhases")) + ), system_prompt=_optional_str_list(payload, "systemPrompt"), dump_tools=dump_tools, ) @@ -1181,8 +1321,12 @@ def parse_compaction_result(payload: JsonObject) -> CompactionResult: short_summary=_optional_str(payload, "shortSummary"), first_kept_entry_id=str(payload.get("firstKeptEntryId", "")), tokens_before=int(payload.get("tokensBefore", 0)), - details=_clone_json_value(payload.get("details"), field="compaction.details") if "details" in payload else None, - preserve_data=_optional_json_object(payload.get("preserveData"), field="compaction.preserveData"), + details=_clone_json_value(payload.get("details"), field="compaction.details") + if "details" in payload + else None, + preserve_data=_optional_json_object( + payload.get("preserveData"), field="compaction.preserveData" + ), ) @@ -1199,7 +1343,9 @@ def parse_model_cycle_result(payload: JsonObject | None) -> ModelCycleResult | N ) -def parse_thinking_level_cycle_result(payload: JsonObject | None) -> ThinkingLevelCycleResult | None: +def parse_thinking_level_cycle_result( + payload: JsonObject | None, +) -> ThinkingLevelCycleResult | None: if payload is None or payload.get("level") is None: return None return ThinkingLevelCycleResult(level=cast(ThinkingLevel, payload["level"])) @@ -1211,7 +1357,10 @@ def parse_cancellation_result(payload: JsonObject | None) -> CancellationResult: def parse_branch_result(payload: JsonObject | None) -> BranchResult: payload = payload or {} - return BranchResult(text=str(payload.get("text", "")), cancelled=bool(payload.get("cancelled", False))) + return BranchResult( + text=str(payload.get("text", "")), + cancelled=bool(payload.get("cancelled", False)), + ) def parse_branch_messages(payload: JsonObject | None) -> tuple[BranchMessage, ...]: @@ -1220,7 +1369,9 @@ def parse_branch_messages(payload: JsonObject | None) -> tuple[BranchMessage, .. raise ValueError("messages must be a list") return tuple( BranchMessage( - entry_id=str(_clone_json_object(item, field="messages[]").get("entryId", "")), + entry_id=str( + _clone_json_object(item, field="messages[]").get("entryId", "") + ), text=str(_clone_json_object(item, field="messages[]").get("text", "")), ) for item in messages @@ -1228,7 +1379,9 @@ def parse_branch_messages(payload: JsonObject | None) -> tuple[BranchMessage, .. def parse_session_stats(payload: JsonObject) -> SessionStats: - tokens_payload = _optional_json_object(payload.get("tokens"), field="sessionStats.tokens") or {} + tokens_payload = ( + _optional_json_object(payload.get("tokens"), field="sessionStats.tokens") or {} + ) return SessionStats( session_file=_optional_str(payload, "sessionFile"), session_id=str(payload.get("sessionId", "")), @@ -1254,10 +1407,16 @@ def parse_extension_ui_request(payload: JsonObject) -> ExtensionUiRequest: id=_require_str(payload, "id"), method=cast( ExtensionUiMethod, - _require_literal(payload.get("method"), _EXTENSION_UI_METHOD_VALUES, field="extension_ui_request.method"), + _require_literal( + payload.get("method"), + _EXTENSION_UI_METHOD_VALUES, + field="extension_ui_request.method", + ), ), title=_optional_str(payload, "title"), - options=_tuple_of_strings(payload.get("options"), field="extension_ui_request.options"), + options=_tuple_of_strings( + payload.get("options"), field="extension_ui_request.options" + ), message=_optional_str(payload, "message"), placeholder=_optional_str(payload, "placeholder"), prefill=_optional_str(payload, "prefill"), @@ -1266,12 +1425,18 @@ def parse_extension_ui_request(payload: JsonObject) -> ExtensionUiRequest: target_id=_optional_str(payload, "targetId"), notify_type=cast( NotifyType | None, - _optional_literal(payload.get("notifyType"), _NOTIFY_TYPE_VALUES, field="extension_ui_request.notifyType"), + _optional_literal( + payload.get("notifyType"), + _NOTIFY_TYPE_VALUES, + field="extension_ui_request.notifyType", + ), ), status_key=_optional_str(payload, "statusKey"), status_text=_optional_str(payload, "statusText"), widget_key=_optional_str(payload, "widgetKey"), - widget_lines=_tuple_of_strings(payload.get("widgetLines"), field="extension_ui_request.widgetLines"), + widget_lines=_tuple_of_strings( + payload.get("widgetLines"), field="extension_ui_request.widgetLines" + ), widget_placement=cast( WidgetPlacement | None, _optional_literal( @@ -1303,7 +1468,11 @@ def parse_notification(payload: JsonObject) -> RpcNotification: if event_type == "agent_start": return AgentStartEvent() if event_type == "agent_end": - return AgentEndEvent(messages=parse_agent_messages(cast(JsonValue | None, payload.get("messages")))) + return AgentEndEvent( + messages=parse_agent_messages( + cast(JsonValue | None, payload.get("messages")) + ) + ) if event_type == "turn_start": return TurnStartEvent() if event_type == "turn_end": @@ -1313,25 +1482,35 @@ def parse_notification(payload: JsonObject) -> RpcNotification: field="turn_end.message", ), tool_results=tuple( - _parse_tool_result_message(_clone_json_object(item, field="turn_end.toolResults[]"), field="turn_end.toolResults[]") + _parse_tool_result_message( + _clone_json_object(item, field="turn_end.toolResults[]"), + field="turn_end.toolResults[]", + ) for item in cast(list[Any], payload.get("toolResults") or []) ), ) if event_type == "message_start": return MessageStartEvent( message=_parse_agent_message( - _clone_json_object(payload.get("message"), field="message_start.message"), + _clone_json_object( + payload.get("message"), field="message_start.message" + ), field="message_start.message", ) ) if event_type == "message_update": return MessageUpdateEvent( message=_parse_agent_message( - _clone_json_object(payload.get("message"), field="message_update.message"), + _clone_json_object( + payload.get("message"), field="message_update.message" + ), field="message_update.message", ), assistant_message_event=parse_assistant_message_event( - _clone_json_object(payload.get("assistantMessageEvent"), field="message_update.assistantMessageEvent") + _clone_json_object( + payload.get("assistantMessageEvent"), + field="message_update.assistantMessageEvent", + ) ), ) if event_type == "message_end": @@ -1345,16 +1524,27 @@ def parse_notification(payload: JsonObject) -> RpcNotification: return ToolExecutionStartEvent( tool_call_id=str(payload.get("toolCallId", "")), tool_name=str(payload.get("toolName", "")), - args=_clone_json_value(payload.get("args"), field="tool_execution_start.args") if "args" in payload else None, + args=_clone_json_value( + payload.get("args"), field="tool_execution_start.args" + ) + if "args" in payload + else None, intent=_optional_str(payload, "intent"), ) if event_type == "tool_execution_update": return ToolExecutionUpdateEvent( tool_call_id=str(payload.get("toolCallId", "")), tool_name=str(payload.get("toolName", "")), - args=_clone_json_value(payload.get("args"), field="tool_execution_update.args") if "args" in payload else None, + args=_clone_json_value( + payload.get("args"), field="tool_execution_update.args" + ) + if "args" in payload + else None, partial_result=( - _clone_json_value(payload.get("partialResult"), field="tool_execution_update.partialResult") + _clone_json_value( + payload.get("partialResult"), + field="tool_execution_update.partialResult", + ) if "partialResult" in payload else None ), @@ -1363,18 +1553,30 @@ def parse_notification(payload: JsonObject) -> RpcNotification: return ToolExecutionEndEvent( tool_call_id=str(payload.get("toolCallId", "")), tool_name=str(payload.get("toolName", "")), - result=_clone_json_value(payload.get("result"), field="tool_execution_end.result") if "result" in payload else None, + result=_clone_json_value( + payload.get("result"), field="tool_execution_end.result" + ) + if "result" in payload + else None, is_error=_optional_bool(payload, "isError"), ) if event_type == "auto_compaction_start": return AutoCompactionStartEvent( reason=cast( Literal["threshold", "overflow", "idle"], - _require_literal(payload.get("reason", "threshold"), _AUTO_COMPACTION_REASON_VALUES, field="auto_compaction_start.reason"), + _require_literal( + payload.get("reason", "threshold"), + _AUTO_COMPACTION_REASON_VALUES, + field="auto_compaction_start.reason", + ), ), action=cast( Literal["context-full", "handoff"], - _require_literal(payload.get("action", "context-full"), _AUTO_COMPACTION_ACTION_VALUES, field="auto_compaction_start.action"), + _require_literal( + payload.get("action", "context-full"), + _AUTO_COMPACTION_ACTION_VALUES, + field="auto_compaction_start.action", + ), ), ) if event_type == "auto_compaction_end": @@ -1382,10 +1584,18 @@ def parse_notification(payload: JsonObject) -> RpcNotification: return AutoCompactionEndEvent( action=cast( Literal["context-full", "handoff"], - _require_literal(payload.get("action", "context-full"), _AUTO_COMPACTION_ACTION_VALUES, field="auto_compaction_end.action"), + _require_literal( + payload.get("action", "context-full"), + _AUTO_COMPACTION_ACTION_VALUES, + field="auto_compaction_end.action", + ), ), result=( - parse_compaction_result(_clone_json_object(result_payload, field="auto_compaction_end.result")) + parse_compaction_result( + _clone_json_object( + result_payload, field="auto_compaction_end.result" + ) + ) if result_payload is not None else None ), @@ -1414,9 +1624,15 @@ def parse_notification(payload: JsonObject) -> RpcNotification: role=str(payload.get("role", "")), ) if event_type == "retry_fallback_succeeded": - return RetryFallbackSucceededEvent(model=str(payload.get("model", "")), role=str(payload.get("role", ""))) + return RetryFallbackSucceededEvent( + model=str(payload.get("model", "")), role=str(payload.get("role", "")) + ) if event_type == "ttsr_triggered": - return TtsrTriggeredEvent(rules=_clone_json_objects(payload.get("rules"), field="ttsr_triggered.rules")) + return TtsrTriggeredEvent( + rules=_clone_json_objects( + payload.get("rules"), field="ttsr_triggered.rules" + ) + ) if event_type == "todo_reminder": return TodoReminderEvent( todos=tuple( @@ -1428,4 +1644,6 @@ def parse_notification(payload: JsonObject) -> RpcNotification: ) if event_type == "todo_auto_clear": return TodoAutoClearEvent() - return UnknownNotification(payload=_clone_json_object(payload, field="notification")) + return UnknownNotification( + payload=_clone_json_object(payload, field="notification") + ) diff --git a/python/omp-rpc/tests/test_client.py b/python/omp-rpc/tests/test_client.py index d46f005fc..b62696d9c 100644 --- a/python/omp-rpc/tests/test_client.py +++ b/python/omp-rpc/tests/test_client.py @@ -576,7 +576,12 @@ BROKEN_STARTUP_SERVER = textwrap.dedent( class RpcClientTests(unittest.TestCase): def make_client(self, server: str = FAKE_SERVER, **kwargs: object) -> RpcClient: - return RpcClient(command=[sys.executable, "-u", "-c", server], startup_timeout=2.0, request_timeout=2.0, **kwargs) + return RpcClient( + command=[sys.executable, "-u", "-c", server], + startup_timeout=2.0, + request_timeout=2.0, + **kwargs, + ) def test_command_builder_supports_common_rpc_options(self) -> None: client = RpcClient( @@ -622,7 +627,9 @@ class RpcClientTests(unittest.TestCase): with self.make_client() as client: state = client.get_state() self.assertEqual(state.session_id, "fake-session") - self.assertEqual(state.model.id if state.model else None, "claude-sonnet-4-5") + self.assertEqual( + state.model.id if state.model else None, "claude-sonnet-4-5" + ) result = client.bash("echo hello") self.assertEqual(result.output, "hello\n") @@ -658,11 +665,21 @@ class RpcClientTests(unittest.TestCase): self.assertEqual(state.dump_tools[-1].name, "echo_host") turn = client.prompt_and_wait("needs host tool", timeout=2.0) - update_events = [event for event in turn.events if getattr(event, "type", None) == "tool_execution_update"] - end_events = [event for event in turn.events if getattr(event, "type", None) == "tool_execution_end"] + update_events = [ + event + for event in turn.events + if getattr(event, "type", None) == "tool_execution_update" + ] + end_events = [ + event + for event in turn.events + if getattr(event, "type", None) == "tool_execution_end" + ] self.assertEqual(len(update_events), 1) - self.assertEqual(update_events[0].partial_result["content"][0]["text"], "working:hello") + self.assertEqual( + update_events[0].partial_result["content"][0]["text"], "working:hello" + ) self.assertEqual(len(end_events), 1) self.assertEqual(end_events[0].result["content"][0]["text"], "host:hello") @@ -679,7 +696,9 @@ class RpcClientTests(unittest.TestCase): seen_methods: list[str] = [] with self.make_client() as client: - client.install_headless_ui(on_request=lambda request: seen_methods.append(request.method)) + client.install_headless_ui( + on_request=lambda request: seen_methods.append(request.method) + ) client.prompt_and_wait("needs ui", timeout=2.0) self.assertEqual(seen_methods, ["input"]) @@ -690,7 +709,9 @@ class RpcClientTests(unittest.TestCase): notification_types: list[str] = [] client = self.make_client() client.on_ready(lambda event: ready_types.append(event.type)) - client.on_notification(lambda notification: notification_types.append(notification.type)) + client.on_notification( + lambda notification: notification_types.append(notification.type) + ) client.on_turn_start(lambda event: event_types.append(event.type)) client.on_message_update(lambda event: event_types.append(event.type)) client.on_agent_end(lambda event: event_types.append(event.type)) @@ -729,7 +750,10 @@ class RpcClientTests(unittest.TestCase): self.assertEqual(cycled.model.id, "claude-sonnet-4-5") available = client.get_available_models() - self.assertEqual([item.id for item in available], ["claude-sonnet-4-5", "claude-sonnet-4-6"]) + self.assertEqual( + [item.id for item in available], + ["claude-sonnet-4-5", "claude-sonnet-4-6"], + ) client.set_thinking_level("high") self.assertEqual(client.get_state().thinking_level, "high") @@ -853,8 +877,12 @@ class RpcClientTests(unittest.TestCase): seen_unknown: list[str] = [] with self.make_client() as client: - client.on_extension_error(lambda event: seen_extension_errors.append(event.error)) - client.on_unknown_notification(lambda event: seen_unknown.append(str(event.payload.get("type")))) + client.on_extension_error( + lambda event: seen_extension_errors.append(event.error) + ) + client.on_unknown_notification( + lambda event: seen_unknown.append(str(event.payload.get("type"))) + ) client.prompt_and_wait("notifications", timeout=2.0) self.assertEqual(seen_extension_errors, ["boom"]) @@ -879,20 +907,32 @@ class RpcClientTests(unittest.TestCase): errors: list[BaseException] = [] with self.make_client() as client: + def run_prompt() -> None: try: - results.append(client.prompt_and_wait("slow", timeout=2.0).require_assistant_text()) - except BaseException as exc: # pragma: no cover - defensive thread capture + results.append( + client.prompt_and_wait( + "slow", timeout=2.0 + ).require_assistant_text() + ) + except ( + BaseException + ) as exc: # pragma: no cover - defensive thread capture errors.append(exc) thread = threading.Thread(target=run_prompt) thread.start() deadline = time.time() + 1.0 - while client._prompt_lifecycle.active_operation != "prompt_and_wait" and time.time() < deadline: + while ( + client._prompt_lifecycle.active_operation != "prompt_and_wait" + and time.time() < deadline + ): time.sleep(0.01) - self.assertEqual(client._prompt_lifecycle.active_operation, "prompt_and_wait") + self.assertEqual( + client._prompt_lifecycle.active_operation, "prompt_and_wait" + ) with self.assertRaises(RpcConcurrencyError): client.collect_events(timeout=1.0) @@ -904,7 +944,11 @@ class RpcClientTests(unittest.TestCase): def test_listener_mutation_does_not_change_retained_turn(self) -> None: with self.make_client() as client: - client.on_message_end(lambda event: event.message["content"].__setitem__(0, {"type": "text", "text": "mutated"})) + client.on_message_end( + lambda event: event.message["content"].__setitem__( + 0, {"type": "text", "text": "mutated"} + ) + ) turn = client.prompt_and_wait("say hello", timeout=2.0) messages = client.get_messages() @@ -941,12 +985,16 @@ class RpcClientTests(unittest.TestCase): listener_errors: list[tuple[str, str | None, str]] = [] client = self.make_client() client.on_notification( - lambda notification: (_ for _ in ()).throw(RuntimeError("boom")) - if notification.type == "turn_start" - else None + lambda notification: ( + (_ for _ in ()).throw(RuntimeError("boom")) + if notification.type == "turn_start" + else None + ) ) client.on_listener_error( - lambda event: listener_errors.append((event.listener_kind, event.source_type, str(event.error))) + lambda event: listener_errors.append( + (event.listener_kind, event.source_type, str(event.error)) + ) ) try: @@ -986,7 +1034,6 @@ class RpcClientTests(unittest.TestCase): self.assertIn("max_event_history", str(ctx.exception)) - HANGING_SERVER = textwrap.dedent( """ import json @@ -1051,22 +1098,32 @@ class StopUnblocksPromptAndWaitTests(unittest.TestCase): # Wait until the prompt is in flight. deadline = time.time() + 2.0 - while client._prompt_lifecycle.active_operation != "prompt_and_wait" and time.time() < deadline: + while ( + client._prompt_lifecycle.active_operation != "prompt_and_wait" + and time.time() < deadline + ): time.sleep(0.01) - self.assertEqual(client._prompt_lifecycle.active_operation, "prompt_and_wait") + self.assertEqual( + client._prompt_lifecycle.active_operation, "prompt_and_wait" + ) t0 = time.time() client.stop() thread.join(timeout=2.0) elapsed = time.time() - t0 - self.assertFalse(thread.is_alive(), "prompt_and_wait did not return after stop()") - self.assertLess(elapsed, 2.0, f"stop() took {elapsed:.2f}s to unblock prompt_and_wait") + self.assertFalse( + thread.is_alive(), "prompt_and_wait did not return after stop()" + ) + self.assertLess( + elapsed, 2.0, f"stop() took {elapsed:.2f}s to unblock prompt_and_wait" + ) self.assertEqual(len(errors), 1) self.assertIsInstance(errors[0], RpcProcessExitError) finally: # stop() is idempotent; safe to call again on cleanup paths. client.stop() + if __name__ == "__main__": unittest.main() diff --git a/python/omp-rpc/tests/test_host_uris.py b/python/omp-rpc/tests/test_host_uris.py index 486d96a11..dc01799ec 100644 --- a/python/omp-rpc/tests/test_host_uris.py +++ b/python/omp-rpc/tests/test_host_uris.py @@ -2,12 +2,11 @@ from __future__ import annotations import sys import textwrap -import threading import time import unittest from omp_rpc import RpcClient, host_uri -from omp_rpc.host_uris import HostUri, normalize_read_result +from omp_rpc.host_uris import normalize_read_result URI_SERVER = textwrap.dedent( @@ -114,7 +113,9 @@ class HostUriHelperTests(unittest.TestCase): def test_normalize_read_result_rejects_invalid_content_type(self) -> None: with self.assertRaises(ValueError): - normalize_read_result({"content": "x", "content_type": "application/octet-stream"}) # type: ignore[arg-type] + normalize_read_result( + {"content": "x", "content_type": "application/octet-stream"} + ) # type: ignore[arg-type] def test_host_uri_helper_normalizes_scheme(self) -> None: uri = host_uri(scheme=" DB ", read=lambda url, ctx: "x") @@ -125,7 +126,9 @@ class HostUriHelperTests(unittest.TestCase): host_uri(scheme="", read=lambda url, ctx: "x") def test_host_uri_writable_when_write_supplied(self) -> None: - uri = host_uri(scheme="db", read=lambda url, ctx: "x", write=lambda url, content, ctx: None) + uri = host_uri( + scheme="db", read=lambda url, ctx: "x", write=lambda url, content, ctx: None + ) self.assertTrue(uri.writable) @@ -168,7 +171,9 @@ class RpcHostUriBridgeTests(unittest.TestCase): "immutable": True, } - with self._make_client(host_uris=(host_uri(scheme="db", read=read_db),)) as client: + with self._make_client( + host_uris=(host_uri(scheme="db", read=read_db),) + ) as client: client._request("trigger_read", url="db://users/42") # type: ignore[attr-defined] frame = self._await_echo(client) self.assertEqual(frame["content"], '{"name":"Alice"}') @@ -212,7 +217,9 @@ class RpcHostUriBridgeTests(unittest.TestCase): def read_db(_url: str, _ctx) -> str: raise RuntimeError("boom") - with self._make_client(host_uris=(host_uri(scheme="db", read=read_db),)) as client: + with self._make_client( + host_uris=(host_uri(scheme="db", read=read_db),) + ) as client: client._request("trigger_read", url="db://users/42") # type: ignore[attr-defined] frame = self._await_echo(client) self.assertTrue(frame.get("isError")) diff --git a/python/omp-rpc/tests/test_protocol.py b/python/omp-rpc/tests/test_protocol.py index c26519fcd..ff641f19b 100644 --- a/python/omp-rpc/tests/test_protocol.py +++ b/python/omp-rpc/tests/test_protocol.py @@ -207,7 +207,9 @@ class ProtocolParsingTests(unittest.TestCase): ) self.assertEqual(state.system_prompt, ()) - def test_parse_session_state_rejects_non_string_in_system_prompt_array(self) -> None: + def test_parse_session_state_rejects_non_string_in_system_prompt_array( + self, + ) -> None: with self.assertRaises(ValueError): parse_session_state( { @@ -233,7 +235,9 @@ class ProtocolParsingTests(unittest.TestCase): def test_parse_extension_ui_request_rejects_invalid_method(self) -> None: with self.assertRaises(ValueError): - parse_notification({"type": "extension_ui_request", "id": "ui-1", "method": "launch"}) + parse_notification( + {"type": "extension_ui_request", "id": "ui-1", "method": "launch"} + ) def test_parse_message_update_rejects_invalid_assistant_done_reason(self) -> None: with self.assertRaises(ValueError): diff --git a/python/omp-rpc/tests/test_user_group.py b/python/omp-rpc/tests/test_user_group.py index 8f6b41fdc..13bb1e9c5 100644 --- a/python/omp-rpc/tests/test_user_group.py +++ b/python/omp-rpc/tests/test_user_group.py @@ -13,7 +13,9 @@ class _Sentinel(Exception): def _start_and_capture(**kwargs): client = RpcClient(**kwargs) - with patch("omp_rpc.client.subprocess.Popen", side_effect=_Sentinel("aborted")) as mock_popen: + with patch( + "omp_rpc.client.subprocess.Popen", side_effect=_Sentinel("aborted") + ) as mock_popen: with pytest.raises(_Sentinel): client.start() assert mock_popen.call_count == 1 diff --git a/python/robomp/docs/pr-review-handoff.md b/python/robomp/docs/pr-review-handoff.md new file mode 100644 index 000000000..a70da8543 --- /dev/null +++ b/python/robomp/docs/pr-review-handoff.md @@ -0,0 +1,373 @@ +# Handoff: incoming-PR review feature + +Wire robomp to **review pull requests opened by contributors** (and other bots), in two +phases: (1) classify + rank, (2) a real line-by-line review posted as one GitHub review. +robomp **never merges, closes, approves, or pushes** — the rank label is the verdict; the +maintainer acts on it. + +Confirmed decisions: +- **COMMENT-only.** `submit_pr_review` always uses `event="COMMENT"`. Never `APPROVE` / + `REQUEST_CHANGES` (those gate merge — the maintainer's call). +- **SQLite staging.** Inline comments are staged in a sqlite table, flushed in one review. + Survives `--continue` resume; honours "DB is the only source of truth, in-memory state is + just `_inflight`." +- **Reuse the issue isolation verbatim.** The PR head is checked out into a per-PR worktree + (clone pool + slot uid + natives cache + scrubbed env) **before** the agent starts, and + that worktree is the agent's cwd. Review is read-only on that checkout. + +The agent prompt already exists: `src/prompts/pr_review_rubric.md` (rename to +`kickoff_pr_review.md` — see §5). Everything below is the wiring around it. + +--- + +## 1. How the existing flows work (the substrate to mirror) + +End-to-end, every task today follows the same spine: + +``` +GitHub webhook + └─ server.py POST /webhook/github (HMAC verify; 401 on bad sig) + └─ github_events.route(event_type, payload, …) → RouteDecision(queue|skip, task, …) + └─ db.record_event(...) INSERT OR IGNORE on X-GitHub-Delivery → 202 + WorkerPool._dispatch_loop (BEGIN IMMEDIATE claim; _inflight set keyed by (owner,repo,number)) + └─ WorkerPool._dispatch(row) re-derives handler from (event_type, action) + └─ tasks.(settings, db, github, sandbox, git_transport, payload, delivery_id, …) + ├─ resolve RepoInfo + IssueInfo (PRs are issues) + ├─ sandbox.ensure_workspace(...) → per-issue worktree (clone pool, slot, natives) + ├─ db.upsert_issue(...) → state + branch + session_dir + └─ worker.run_task(task_kind=..., inputs=TaskInputs, …) + ├─ ToolBindings(inbound_thread_number=pr_number, inbound_is_pr=…) + ├─ _build_prompt(task_kind, …) → persona.(...) + ├─ RpcClient(omp --mode rpc, cwd=worktree, custom_tools=host_tools.build(bindings)) + └─ _drive_turn(...) → completion/dirty reminders until terminal tool / clean +``` + +Existing task kinds and their analogues for us: + +| Task | Trigger (`route`) | Workspace | Terminal action | Notes | +|---|---|---|---|---| +| `triage_issue` | `issues.opened` | fresh `farm//` worktree | `gh_open_pr` / `mark_unable_to_reproduce` / `abort_task` | **The fresh-entry template** for `review_pr`. | +| `handle_comment` | `issue_comment.created` on an issue | resume existing | one `gh_post_comment` | | +| `handle_pr_conversation` | `issue_comment.created` on a PR | resume bot-PR branch | `gh_post_comment` / push | bot-owned PRs only. | +| `handle_review` | `pull_request_review_comment.created` on a **bot-authored** PR | resume via `existing_branch=pr.head_ref` | reply / push | **The PR-context template** — shows `ensure_workspace(existing_branch=…)` and `inbound_is_pr`. | +| `cleanup_workspace` | `issues.closed` / bot `pull_request.merged` | removes worktree | — | | + +Two facts that shape the wiring: +- `route()` and `WorkerPool._dispatch()` **both** branch on `(event_type, action)`. `route` + decides queue/skip + carries `submitter`/`directive`; `_dispatch` re-derives the handler. + **A new task kind must be added in both.** +- `host_tools.build(bindings)` returns an **identical tuple for every task kind** (to keep the + LLM prompt cache warm); tools **self-gate at execution time** (e.g. `classify_issue` rejects + when `bindings.inbound_is_pr`). We follow the same pattern: add the new tools to `build()` + unconditionally and gate them on a `review_mode` flag. + +--- + +## 2. Routing (`src/github_events.py`) + +### 2a. New entry: incoming PR opened + +Add a `pull_request` branch **before** the existing `pull_request`/`closed` block. Trigger a +one-shot review on `opened`, `reopened`, and `ready_for_review`; **skip everything else** +(notably `synchronize` already falls through to the final skip — keep it that way: do **not** +re-review on new commits). + +```python +if event_type == "pull_request" and action in ("opened", "reopened", "ready_for_review"): + pr = payload.get("pull_request") or {} + if bool(pr.get("draft")): + return RouteDecision("skip", None, repo, None, "draft PR") + pr_user = pr.get("user") or {} + if _is_bot_account(pr_user, bot_login): + return RouteDecision("skip", None, repo, None, "bot-authored PR") # our own farm PRs + number = pr.get("number") + if not isinstance(number, int): + return RouteDecision("skip", None, repo, None, "PR missing number") + login, assoc = _submitter_info(pr) # PR author = rate-limit subject + return RouteDecision("queue", "review_pr", repo, issue_key(repo, number), + f"pull_request.{action}", submitter=login, association=assoc) +``` + +Use the PR's **own** key (`issue_key(repo, number)`), not `_resolve_pr_key` — an incoming PR +has no originating bot issue. + +### 2b. Gate incoming-PR comments ("don't run on comments unless I ask") + +Today `issue_comment.created` on **any** PR queues `handle_pr_conversation`. For incoming +(non-bot) PRs that would make the bot respond to every comment. Change the PR branch of the +`issue_comment` handler to: + +- PR author **is** the bot → `handle_pr_conversation` (unchanged). +- PR author is **not** the bot → **skip**, *unless* `_directive_kwargs(...)` is non-empty + (a maintainer `@bot` mention or a configured reviewer bot). A directive routes to the + existing directive path; only an explicit "re-review" directive re-runs the review. + +The PR author is on `payload.issue.user.login` for `issue_comment` events. `synchronize`, +`edited`, etc. need no change (they already skip). + +### 2c. Cleanup for incoming PRs + +`pull_request.closed` currently requires a bot-authored, merged PR. Incoming-PR review +worktrees would otherwise leak. Extend the close branch (or add a TTL sweep) so an incoming +PR's worktree is GC'd on close. Minimal: when the PR has a workspace row, route +`pull_request.closed` → `cleanup_workspace` regardless of author/merge. + +--- + +## 3. Dispatch (`src/queue.py` `_dispatch`) + +Add a branch mirroring `triage_issue`: + +```python +elif event == "pull_request" and action in ("opened", "reopened", "ready_for_review"): + await tasks.review_pr( + settings=self.settings, db=self.db, github=self.github, + sandbox=self.sandbox, git_transport=self.git_transport, + payload=row.payload, delivery_id=row.delivery_id, + attempts=row.attempts, slot_uid=slot_uid, + ) +``` + +Idempotency: `record_event` dedups on delivery id and `_inflight` serializes per +`(owner,repo,number)`. Add one guard in `tasks.review_pr`: if the PR already carries a +`triaged`/`review:*` label, skip the re-review (a `reopened` shouldn't redo work) unless a +directive forces it. + +--- + +## 4. Task dispatcher (`src/tasks.py` `review_pr`) + +New entry point — structurally `triage_issue` (fresh worktree) crossed with `handle_review` +(PR context). Key differences: it checks out the **PR head**, and it never opens an issue row +for an originating issue (the PR is the unit). + +```python +async def review_pr(*, settings, db, github, sandbox, git_transport, + payload, delivery_id, attempts=0, slot_uid=None) -> None: + pr_node = payload.get("pull_request") or {} + pr_number = int(pr_node.get("number") or 0) + repo_full = str((payload.get("repository") or {}).get("full_name") or "") + if pr_number <= 0 or not repo_full: + return + repo = await github.get_repo(repo_full) + issue = await github.get_issue(repo_full, pr_number) # PR-as-issue → title/body/labels + pr = await github.get_pull_request(repo_full, pr_number) + + # idempotency: already triaged? bail (see §3) + key = issue_key(repo_full, pr_number) + db.upsert_issue(key=key, repo=repo_full, number=pr_number, state="reviewing", pr_number=pr_number) + + workspace = sandbox.ensure_workspace( + repo=repo.full_name, number=pr_number, title=issue.title, + clone_url=repo.clone_url, default_branch=repo.default_branch, + pr_head=pr_number, # ← NEW: check out the PR head (see §6) + author_name=settings.resolved_author_name, author_email=settings.git_author_email, + slot_uid=slot_uid, + ) + db.upsert_issue(key=key, repo=repo_full, number=pr_number, state="reviewing", + branch=workspace.branch, session_dir=str(workspace.session_dir), pr_number=pr_number) + + inputs = TaskInputs(settings=settings, db=db, github=github, git_transport=git_transport, + repo=repo, issue=issue, workspace=workspace, delivery_id=delivery_id, + attempts=attempts, slot_uid=slot_uid, natives_cache=sandbox.natives_cache) + await run_task(task_kind="review_pr", inputs=inputs, pr_number=pr_number) +``` + +`run_task(..., pr_number=pr_number)` makes `ToolBindings.inbound_is_pr=True` and points the +comment tools at the PR thread (existing behavior). Add `review_pr` to `tasks.__all__`. + +--- + +## 5. Prompt + persona (`src/persona.py`, `src/prompts/`) + +- **Rename** `src/prompts/pr_review_rubric.md` → `src/prompts/kickoff_pr_review.md` (it's the + full kickoff now, not just a rubric). +- Add the loader, mirroring `kickoff`: + + ```python + def kickoff_pr_review(*, repo: RepoInfo, pr: PullRequestInfo, workspace: Workspace) -> str: + return render(_load("kickoff_pr_review.md"), {"repo": repo, "pr": pr, "workspace": workspace}) + ``` + + The template references `{{repo.*}}`, `{{pr.number|author|head_ref|base_ref|head_repo|html_url}}`, + `{{workspace.branch}}`. Title/body/diff come from the `fetch_pr` tool, not template vars + (`PullRequestInfo` has no title/body). `_lookup` returns `""` for any missing field — safe. +- `_build_prompt` (`worker.py`): add a `task_kind == "review_pr"` branch calling + `persona.kickoff_pr_review(repo=inputs.repo, pr=, workspace=inputs.workspace)`. The `pr` + object must reach `_build_prompt` — simplest is to add an optional `pr: PullRequestInfo | None` + param to `run_task`/`_build_prompt` (parallel to `comment`/`review_payload`), or rebuild it + from `inputs.issue` (number/author) + a `get_pull_request` call inside the branch. +- **`todo_phases.toml`**: add a `review_pr` table (Phase 0 orient / Phase 1 classify / Phase 2 + review) so `seed_phases("review_pr")` seeds the todo list, like `triage_issue`. +- **`host_tools.toml`**: add descriptions for the four new tools (see §7). + +--- + +## 6. Sandbox: check out the PR head (`src/sandbox.py`) + +This is the load-bearing isolation change. Reuse the entire worktree machinery; only the +**checkout source** differs. The PR head may live on a fork, so it is fetched via +`refs/pull//head` on the base repo's remote (not a branch on origin). + +- **`GitTransport` protocol** — add: + ```python + def fetch_pr_head(self, *, repo: str, pool_dir: Path, pr_number: int) -> None: ... + ``` + `LocalGitTransport`: `git fetch origin pull//head` (PAT injected per-call, as + `fetch_base_ref` does). `ProxyGitTransport`: add the matching gh-proxy git op (mirror its + `fetch_base_ref` path over the HMAC channel + a proxy-server handler). +- **`ensure_workspace`** — add `pr_head: int | None = None`. When set, in the `not repo_exists` + branch: + ```python + self.transport.fetch_pr_head(repo=repo, pool_dir=pool, pr_number=pr_head) + _run(["git", "worktree", "add", "--detach", str(repo_dir), "FETCH_HEAD"], cwd=pool) + ``` + Detached HEAD (never a pushable branch — review is read-only). Set + `workspace.branch = f"review/pr-{pr_head}"` for bookkeeping/logging only. Everything after + (slot chown, `_share_git_metadata_with_slots`, `_provision_runtime_dirs`, natives-cache + hardlink, identity config) runs unchanged, so the review worktree gets the **same isolation + and the warm native cache** as a fix worktree (`bun check`/lsp stay fast). + +Result: the agent's cwd is the PR head, fully isolated, read-only. No credentialed push remote +is configured for review worktrees. + +--- + +## 7. Host tools (`src/host_tools.py`) + +Add a `review_mode: bool = False` field to `ToolBindings`; set it from `run_task` +(`review_mode = task_kind == "review_pr"`). Self-gating pattern (consistent with how +`classify_issue` gates on `inbound_is_pr`): + +- New review tools require `review_mode` → reject otherwise. +- `gh_push_branch` / `gh_open_pr` **refuse** when `review_mode` (read-only review; never push to + a contributor's branch). + +Four new tools, registered unconditionally in `build()`: + +| Tool | Params | Behavior | Audit | +|---|---|---|---| +| `fetch_pr` | — (defaults to inbound PR) | `get_pull_request` + `list_pr_files`; returns title, body, `Fixes #N` links, changed-file list (path/status/+−). The premise read. | yes | +| `classify_pr` | `rank`(req `review:p0..p3`), `type`(one of `_PR_TYPES`), `area[]`(⊆ `_FUNCTIONAL`), `provider?`, `rationale` | Validate (drop unknowns silently, like `classify_issue`); `github.add_issue_labels(repo, pr.number, ["triaged", rank, type, *area, provider?])` (issues-labels API works on PRs); persist rank in the issue row. | yes | +| `pr_review_comment` | `path`(req), `line`(req int), `body`(req), `side`="RIGHT", `start_line?`, `start_side?` | **Stage only** — append to sqlite (§9). Validate path/line/body. Return staged count. No GitHub call. | yes | +| `submit_pr_review` | `body`(req), `event`="COMMENT" (forced) | Read staged rows → `github.submit_pr_review(repo, pr.number, body, "COMMENT", comments)` → `clear_staged_review_comments` on success. | yes | + +New allowlists next to the existing ones: +```python +_PR_RANKS = ("review:p0", "review:p1", "review:p2", "review:p3") +_PR_TYPES = ("feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build") +# area reuses _FUNCTIONAL; provider: + _PLATFORMS as for classify_issue +``` + +`classify_pr` mirrors `_build_classify_issue` (validation + label apply + persist + audit). +`submit_pr_review` clears the buffer only after a 2xx so a failed post is retryable. + +--- + +## 8. Backend (`github_backend.py` + `github_client.py` + `proxy_client.py` + `proxy/server.py`) + +- `PullRequestInfo`: add `title: str = ""`, `body: str = ""`. Populate in `_pr_from_payload` + (REST `/pulls/{n}` carries both) and proxy `_pr_from`. +- `GitHubBackend` protocol + both impls: + - `list_pr_files(repo, pr_number) -> list[PullRequestFileInfo]` → `GET /pulls/{n}/files` + (new small frozen dataclass: `path`, `status`, `additions`, `deletions`). + - `submit_pr_review(*, repo, pr_number, body, event, comments) -> PullRequestReviewInfo` + → `POST /pulls/{n}/reviews` with `comments=[{path, line, side, body, start_line?, start_side?}]`. +- gh-proxy mode (`proxy_client.py` + `src/proxy/server.py`): add `/gh/v1/pr_files` (GET) and + `/gh/v1/submit_pr_review` (POST) endpoints + client wrappers. HMAC signing is generic — no + protocol change. Validate inputs server-side with the existing `_require_*` helpers. + +--- + +## 9. DB (`src/db.py`) + +One staging table (schema block near `events`/`issues`/`tool_calls`): + +```sql +CREATE TABLE IF NOT EXISTS pr_review_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_key TEXT NOT NULL, -- repo__owner# + path TEXT NOT NULL, + line INTEGER NOT NULL, + side TEXT NOT NULL DEFAULT 'RIGHT', + start_line INTEGER, + start_side TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pr_review_comments_key ON pr_review_comments(issue_key); +``` + +DAOs (thread-safe via the existing `_lock`): `stage_review_comment(...)`, +`list_staged_review_comments(issue_key) -> list[...]`, `clear_staged_review_comments(issue_key)`. +Rank persistence reuses the `issues` row (`classification`/a new `pr_rank` column) keyed by the +PR's `issue_key`. + +--- + +## 10. Worker completion gate (`src/worker.py`) + +- `_needs_completion_reminder`: extend so `task_kind == "review_pr"` reminds until + `submit_pr_review` is in `tools_called` (the terminal action), mirroring the + `_TERMINAL_TRIAGE_TOOLS` logic. Add a `_TERMINAL_REVIEW_TOOLS = {"submit_pr_review", "abort_task"}`. + Review worktrees are read-only, so the dirty-state reminder is irrelevant — skip it for + `review_pr` (or it'll be clean anyway). +- `run_task`: thread `review_mode`/`pr` through to `ToolBindings`/`_build_prompt` (§5, §7). + +--- + +## 11. Config (`src/config.py`) — optional + +Add `pr_review_enabled: bool = True` (`ROBOMP_PR_REVIEW_ENABLED`) so the whole flow can be +killed without a redeploy; check it in `route()`'s new branch. Reuse the existing +`repo_allowlist`, `maintainers`, `reviewer_bots`. No new auth. + +--- + +## 12. Routing truth table + +| Event | Condition | Result | +|---|---|---| +| `pull_request.opened` / `reopened` / `ready_for_review` | non-draft, author ≠ bot, allowlisted, enabled | **`review_pr`** | +| `pull_request.opened` | draft / bot-authored | skip | +| `pull_request.synchronize` (new commits) | — | **skip** (no re-review) | +| `pull_request.edited` / others | — | skip | +| `issue_comment.created` on incoming PR | not a directive | **skip** | +| `issue_comment.created` on incoming PR | maintainer `@bot` / reviewer bot | directive path (may re-review) | +| `issue_comment.created` on bot PR | — | `handle_pr_conversation` (unchanged) | +| `pull_request.closed` | has review workspace | `cleanup_workspace` | + +--- + +## 13. Test plan (`tests/`, `pytest`, `httpx.MockTransport`) + +Mirror existing test style; assert observable contracts, never internals. + +- **Routing** (`test_github_events.py`): `pull_request.opened` → `review_pr`; draft/bot/non-allowlist + → skip; `synchronize` → skip; incoming-PR comment → skip unless directive. +- **classify_pr** (`test_host_tools.py`): happy path applies `triaged`+`review:pN`+type+area + (assert the labels in the mocked `add_issue_labels` call); bad rank → validation error; unknown + area dropped silently. +- **Staging + submit**: `pr_review_comment` writes rows (assert via DB); `submit_pr_review` posts + one review with all staged comments + `event="COMMENT"` (assert the mocked POST body) and clears + the buffer; second submit with empty buffer posts summary-only / no-ops. +- **review_mode gating**: `gh_push_branch`/`gh_open_pr` refuse under `review_mode`; review tools + refuse outside it. +- **Sandbox** (`test_sandbox.py`, real local bare repo as upstream): `pr_head` checkout yields a + detached worktree at the PR head commit; no push remote configured. +- **Completion gate**: a `review_pr` turn ending before `submit_pr_review` triggers the reminder. + +Do **not** enable the integration smoke (`ROBOMP_INTEGRATION=1`) in the default suite. + +--- + +## 14. Open questions for @can1357 + +1. **Rank label namespace**: `review:p0..p3` (proposed, avoids colliding with issue `prio:p0..p3`) + — or reuse `prio:`? These must exist (or be auto-creatable) as repo labels. +2. **`type` labels**: introduce `feat`/`fix`/`docs`/… as bare labels, or namespace `type:feat`? + The repo's current label set should be checked before `classify_pr` writes them. +3. **Re-review trigger phrasing**: which directive text re-runs Phase 2 vs. just answers a + question? (Routed through the existing directive path.) +4. **Cleanup**: GC incoming-PR review worktrees on `pull_request.closed` (any author), or a TTL + sweep? (§2c.) diff --git a/python/robomp/src/config.py b/python/robomp/src/config.py index bec0815d5..eb7179a4b 100644 --- a/python/robomp/src/config.py +++ b/python/robomp/src/config.py @@ -37,6 +37,7 @@ class Settings(BaseSettings): git_author_name: str | None = Field(None, alias="ROBOMP_GIT_AUTHOR_NAME") git_author_email: str = Field(..., alias="ROBOMP_GIT_AUTHOR_EMAIL") repo_allowlist_raw: str = Field("", alias="ROBOMP_REPO_ALLOWLIST") + pr_review_enabled: bool = Field(True, alias="ROBOMP_PR_REVIEW_ENABLED") # gh-proxy. Set BOTH to route GitHub through the proxy; leave both empty # to keep PAT-on-orchestrator behavior. Mixing the two (PAT + proxy) is diff --git a/python/robomp/src/db.py b/python/robomp/src/db.py index a3d27aee7..0e5762ba9 100644 --- a/python/robomp/src/db.py +++ b/python/robomp/src/db.py @@ -19,6 +19,7 @@ IssueState = Literal[ "new", "reproducing", "fixing", + "reviewing", "opened", "merged", "closed", @@ -72,6 +73,20 @@ CREATE TABLE IF NOT EXISTS tool_calls ( ); CREATE INDEX IF NOT EXISTS tool_calls_issue ON tool_calls(issue_key, ts); +CREATE TABLE IF NOT EXISTS pr_review_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_key TEXT NOT NULL, + path TEXT NOT NULL, + line INTEGER NOT NULL, + side TEXT NOT NULL DEFAULT 'RIGHT', + start_line INTEGER, + start_side TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pr_review_comments_key + ON pr_review_comments(issue_key); + CREATE TABLE IF NOT EXISTS submissions ( delivery_id TEXT PRIMARY KEY, login TEXT NOT NULL, @@ -132,6 +147,19 @@ class IssueRow: classification: str | None = None +@dataclass(slots=True, frozen=True) +class StagedReviewComment: + id: int + issue_key: str + path: str + line: int + side: str + body: str + created_at: str + start_line: int | None = None + start_side: str | None = None + + def _event_row_from_db_row(row: sqlite3.Row) -> EventRow: return EventRow( delivery_id=row["delivery_id"], @@ -778,6 +806,93 @@ class Database: ) return int(cur.lastrowid or 0) + def has_successful_tool_call(self, issue_key: str, tool: str) -> bool: + with self._lock: + row = self._conn.execute( + """ + SELECT 1 + FROM tool_calls + WHERE issue_key=? AND tool=? AND error IS NULL + ORDER BY id DESC + LIMIT 1 + """, + (issue_key, tool), + ).fetchone() + return row is not None + + # ---- PR review comment staging ---- + def stage_review_comment( + self, + *, + issue_key: str, + path: str, + line: int, + body: str, + side: str = "RIGHT", + start_line: int | None = None, + start_side: str | None = None, + ) -> StagedReviewComment: + with self._lock: + cur = self._conn.execute( + """ + INSERT INTO pr_review_comments + (issue_key, path, line, side, start_line, start_side, body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (issue_key, path, line, side, start_line, start_side, body, _utcnow()), + ) + row = self._conn.execute( + """ + SELECT id, issue_key, path, line, side, start_line, start_side, body, created_at + FROM pr_review_comments + WHERE id=? + """, + (int(cur.lastrowid or 0),), + ).fetchone() + assert row is not None + return StagedReviewComment( + id=int(row["id"]), + issue_key=row["issue_key"], + path=row["path"], + line=int(row["line"]), + side=row["side"], + body=row["body"], + created_at=row["created_at"], + start_line=int(row["start_line"]) if row["start_line"] is not None else None, + start_side=row["start_side"], + ) + + def list_staged_review_comments(self, issue_key: str) -> list[StagedReviewComment]: + with self._lock: + rows = self._conn.execute( + """ + SELECT id, issue_key, path, line, side, start_line, start_side, body, created_at + FROM pr_review_comments + WHERE issue_key=? + ORDER BY id + """, + (issue_key,), + ).fetchall() + return [ + StagedReviewComment( + id=int(row["id"]), + issue_key=row["issue_key"], + path=row["path"], + line=int(row["line"]), + side=row["side"], + body=row["body"], + created_at=row["created_at"], + start_line=int(row["start_line"]) if row["start_line"] is not None else None, + start_side=row["start_side"], + ) + for row in rows + ] + + def clear_staged_review_comments(self, issue_key: str) -> int: + with self._lock: + cur = self._conn.execute("DELETE FROM pr_review_comments WHERE issue_key=?", (issue_key,)) + return int(cur.rowcount or 0) + # ---- submissions (per-user rate limiting) ---- def admit_submission( self, diff --git a/python/robomp/src/git_ops.py b/python/robomp/src/git_ops.py index bf6e23480..312319f2d 100644 --- a/python/robomp/src/git_ops.py +++ b/python/robomp/src/git_ops.py @@ -439,6 +439,20 @@ def fetch_ref(repo_dir: Path, ref: str, *, token: str | None, safe_directory: Pa ) +def fetch_pr_head( + repo_dir: Path, + pr_number: int, + *, + token: str | None, + safe_directory: Path | None = None, +) -> None: + """Fetch `refs/pull//head` into FETCH_HEAD for detached PR review worktrees.""" + if pr_number <= 0: + raise ValueError(f"invalid PR number: {pr_number!r}") + args = ["fetch", "origin", f"pull/{pr_number}/head"] + _check(_run_git(args, cwd=repo_dir, token=token, safe_directory=safe_directory), ["git", *args]) + + @dataclass(slots=True, frozen=True) class PushResult: head: str @@ -646,6 +660,7 @@ __all__ = [ "HeadDriftError", "PushResult", "clone", + "fetch_pr_head", "fetch_prune", "fetch_ref", "inspect_dirty_state", diff --git a/python/robomp/src/github_backend.py b/python/robomp/src/github_backend.py index e22a92514..b8ced989f 100644 --- a/python/robomp/src/github_backend.py +++ b/python/robomp/src/github_backend.py @@ -8,12 +8,14 @@ dataclasses (`IssueInfo`, `RepoInfo`, …) defined in `github_client`. from __future__ import annotations -from typing import Protocol +from collections.abc import Mapping +from typing import Any, Protocol from robomp.github_client import ( CommentInfo, IssueInfo, IssueSummary, + PullRequestFileInfo, PullRequestInfo, PullRequestReviewInfo, ReactionInfo, @@ -34,6 +36,8 @@ class GitHubBackend(Protocol): async def get_pull_request(self, repo: str, number: int) -> PullRequestInfo: ... + async def list_pr_files(self, repo: str, pr_number: int) -> list[PullRequestFileInfo]: ... + async def list_issues( self, repo: str, @@ -76,6 +80,16 @@ class GitHubBackend(Protocol): async def add_issue_labels(self, repo: str, number: int, labels: list[str]) -> tuple[str, ...]: ... + async def submit_pr_review( + self, + *, + repo: str, + pr_number: int, + body: str, + event: str, + comments: list[Mapping[str, Any]], + ) -> PullRequestReviewInfo: ... + async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None: ... async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]: ... diff --git a/python/robomp/src/github_client.py b/python/robomp/src/github_client.py index 1a4f429b0..48e3002bb 100644 --- a/python/robomp/src/github_client.py +++ b/python/robomp/src/github_client.py @@ -65,6 +65,16 @@ class PullRequestInfo: state: str author: str = "" head_repo: str = "" + title: str = "" + body: str = "" + + +@dataclass(slots=True, frozen=True) +class PullRequestFileInfo: + path: str + status: str + additions: int + deletions: int @dataclass(slots=True, frozen=True) @@ -254,6 +264,21 @@ class GitHubClient: data = await self.request("GET", f"/repos/{repo}/pulls/{number}") return _pr_from_payload(repo, data) + async def list_pr_files(self, repo: str, pr_number: int) -> list[PullRequestFileInfo]: + files: list[PullRequestFileInfo] = [] + page = 1 + while True: + data = await self.request( + "GET", + f"/repos/{repo}/pulls/{pr_number}/files", + params={"per_page": 100, "page": page}, + ) + batch = [_pr_file_from_payload(item) for item in (data or [])] + files.extend(batch) + if len(batch) < 100: + return files + page += 1 + async def list_issues( self, repo: str, @@ -421,6 +446,22 @@ class GitHubClient: ) return tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in (data or [])) + async def submit_pr_review( + self, + *, + repo: str, + pr_number: int, + body: str, + event: str, + comments: list[Mapping[str, Any]], + ) -> PullRequestReviewInfo: + data = await self.request( + "POST", + f"/repos/{repo}/pulls/{pr_number}/reviews", + json={"body": body, "event": event, "comments": comments}, + ) + return _pr_review_from_payload(data) + async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None: if not assignees: return @@ -482,6 +523,27 @@ def _issue_from_payload(repo: str, data: Mapping[str, Any]) -> IssueInfo: ) +def _pr_review_from_payload(data: Mapping[str, Any]) -> PullRequestReviewInfo: + user = data.get("user") or {} + body = str(data.get("body") or "").strip() + return PullRequestReviewInfo( + id=int(data.get("id") or 0), + author=str(user.get("login") or "") if isinstance(user, Mapping) else "", + body=body, + state=str(data.get("state") or ""), + submitted_at=str(data.get("submitted_at") or data.get("created_at") or ""), + ) + + +def _pr_file_from_payload(data: Mapping[str, Any]) -> PullRequestFileInfo: + return PullRequestFileInfo( + path=str(data.get("filename") or data.get("path") or ""), + status=str(data.get("status") or ""), + additions=int(data.get("additions") or 0), + deletions=int(data.get("deletions") or 0), + ) + + def _pr_from_payload(repo: str, data: Mapping[str, Any]) -> PullRequestInfo: head = data.get("head") or {} base = data.get("base") or {} @@ -496,6 +558,8 @@ def _pr_from_payload(repo: str, data: Mapping[str, Any]) -> PullRequestInfo: state=str(data.get("state") or "open"), author=str(user.get("login") or "") if isinstance(user, Mapping) else "", head_repo=str(head_repo.get("full_name") or "") if isinstance(head_repo, Mapping) else "", + title=str(data.get("title") or ""), + body=str(data.get("body") or ""), ) @@ -534,6 +598,7 @@ __all__ = [ "GitHubError", "IssueInfo", "IssueSummary", + "PullRequestFileInfo", "PullRequestInfo", "PullRequestReviewInfo", "ReactionInfo", diff --git a/python/robomp/src/github_events.py b/python/robomp/src/github_events.py index e6a185a61..1e0c08ceb 100644 --- a/python/robomp/src/github_events.py +++ b/python/robomp/src/github_events.py @@ -31,6 +31,7 @@ class RouteDecision: directive_body: str | None = None directive_author: str | None = None directive_pragmas: tuple[tuple[str, str], ...] = () + directive_authorizes_impl: bool = False @property def should_queue(self) -> bool: @@ -125,6 +126,20 @@ def is_maintainer( return False +def is_implementation_authorizer( + login: str | None, + association: str | None, + *, + maintainers: frozenset[str], +) -> bool: + """Return whether this author may authorize implementation work.""" + if isinstance(login, str) and login and login.lower() in maintainers: + return True + if isinstance(association, str) and association.upper() == "OWNER": + return True + return False + + def route( event_type: str, payload: Mapping[str, Any], @@ -134,6 +149,7 @@ def route( maintainers: frozenset[str] = frozenset(), reviewer_bots: frozenset[str] = frozenset(), resolve_issue_from_pr: PrIssueResolver = None, + pr_review_enabled: bool = True, ) -> RouteDecision: """Decide whether and how to handle a webhook event. @@ -183,6 +199,7 @@ def route( "directive_body": cleaned, "directive_author": rb_login, "directive_pragmas": pragmas, + "directive_authorizes_impl": False, } if not is_maintainer(login, assoc, maintainers=maintainers): return {} @@ -190,11 +207,13 @@ def route( if stripped is None: return {} cleaned, pragmas = parse_pragmas(stripped) + authorizes_impl = is_implementation_authorizer(login, assoc, maintainers=maintainers) return { "directive": True, "directive_body": cleaned, "directive_author": login, "directive_pragmas": pragmas, + "directive_authorizes_impl": authorizes_impl, } if event_type == "issues": @@ -225,23 +244,26 @@ def route( if not isinstance(number, int): return RouteDecision("skip", None, repo, None, "comment missing issue number") if "pull_request" in issue: - # Conversation comment on a PR. The PR number lives at issue.number - # on this payload type. Prefer the originating issue key when the - # DB has it, but do not drop bot-authored follow-ups just because - # the PR mapping was lost; the worker can recover from the PR - # branch or handle the PR directly. + # Conversation comments on incoming contributor PRs are intentionally + # ignored for now: the one-shot review runs on open, and re-review + # directives are not wired yet. Only bot-authored PRs resume a live + # amend-and-push workflow. key = _resolve_pr_key(number) login, assoc = _submitter_info(comment) - return RouteDecision( - "queue", - "handle_pr_conversation", - repo, - key, - f"issue_comment.created on PR #{number}", - submitter=login, - association=assoc, - **_directive_kwargs(comment, login, assoc), - ) + issue_user_raw = issue.get("user") + issue_user = issue_user_raw if isinstance(issue_user_raw, Mapping) else {} + if str(issue_user.get("login") or "") == bot_login: + return RouteDecision( + "queue", + "handle_pr_conversation", + repo, + key, + f"issue_comment.created on PR #{number}", + submitter=login, + association=assoc, + **_directive_kwargs(comment, login, assoc), + ) + return RouteDecision("skip", None, repo, issue_key(repo, number), "incoming PR comments ignored") key = issue_key(repo, number) login, assoc = _submitter_info(comment) return RouteDecision( @@ -255,6 +277,29 @@ def route( **_directive_kwargs(comment, login, assoc), ) + if event_type == "pull_request" and action in ("opened", "reopened", "ready_for_review"): + if not pr_review_enabled: + return RouteDecision("skip", None, repo, None, "PR review disabled") + pr = payload.get("pull_request") or {} + if bool(pr.get("draft")): + return RouteDecision("skip", None, repo, None, "draft PR") + pr_user = pr.get("user") or {} + if _is_bot_account(pr_user, bot_login): + return RouteDecision("skip", None, repo, None, "bot-authored PR") + number = pr.get("number") + if not isinstance(number, int): + return RouteDecision("skip", None, repo, None, "PR missing number") + login, assoc = _submitter_info(pr) + return RouteDecision( + "queue", + "review_pr", + repo, + issue_key(repo, number), + f"pull_request.{action}", + submitter=login, + association=assoc, + ) + if event_type == "pull_request_review_comment" and action == "created": comment = payload.get("comment") or {} rb_login = _reviewer_bot_login(comment.get("user")) @@ -282,15 +327,11 @@ def route( if event_type == "pull_request" and action == "closed": pr = payload.get("pull_request") or {} - pr_user = pr.get("user") or {} - if str(pr_user.get("login") or "") != bot_login: - return RouteDecision("skip", None, repo, None, "PR not bot-authored") - if not bool(pr.get("merged")): - return RouteDecision("skip", None, repo, None, "PR closed without merge") number = pr.get("number") if not isinstance(number, int): return RouteDecision("skip", None, repo, None, "PR missing number") - return RouteDecision("queue", "cleanup_workspace", repo, _resolve_pr_key(number), "pull_request.merged") + reason = "pull_request.merged" if bool(pr.get("merged")) else "pull_request.closed" + return RouteDecision("queue", "cleanup_workspace", repo, _resolve_pr_key(number), reason) return RouteDecision("skip", None, repo, None, f"{event_type}.{action} not handled") @@ -329,6 +370,7 @@ __all__ = [ "TRUSTED_ASSOCIATIONS", "extract_mention", "is_maintainer", + "is_implementation_authorizer", "rate_limit_cap", "route", "verify_signature", diff --git a/python/robomp/src/host_tools.py b/python/robomp/src/host_tools.py index aeee25c76..2b44c050f 100644 --- a/python/robomp/src/host_tools.py +++ b/python/robomp/src/host_tools.py @@ -10,6 +10,7 @@ import asyncio import json import logging import os +import re import subprocess import time from collections.abc import Callable, Mapping @@ -25,7 +26,7 @@ from robomp.config import Settings from robomp.db import Database, issue_key from robomp.git_ops import GitCommandError, HeadDriftError from robomp.github_backend import GitHubBackend -from robomp.github_client import GitHubError, IssueInfo, RepoInfo +from robomp.github_client import GitHubError, IssueInfo, PullRequestFileInfo, RepoInfo from robomp.sandbox import ( GitTransport, Workspace, @@ -109,6 +110,12 @@ class ToolBindings: # — the originating issue has already been classified and the PR # itself does not carry triage labels. inbound_is_pr: bool = False + # True only for incoming-PR review tasks. Review tools require it; mutating + # branch/PR publication tools reject when it is set. + review_mode: bool = False + # Current task is driven by an allowlist/OWNER maintainer directive that + # authorizes implementation. Gates first-PR creation on non-bug/doc issues. + impl_authorized: bool = False slot_uid: int | None = None # Set by the worker before launching omp. Carries the abort-task signal # back out to the worker; `None` for unit tests that exercise tools @@ -514,6 +521,10 @@ def _build_post_comment(bindings: ToolBindings) -> HostTool[Any, Any]: def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_name: str, branch: str) -> str: + if bindings.review_mode: + msg = "refusing to push: PR review worktrees are read-only." + _audit(bindings, tool_name, args, error=msg) + _raise_command(msg) if branch != bindings.workspace.branch: _raise_command( f"refusing to push: branch={branch!r} does not match workspace branch {bindings.workspace.branch!r}." @@ -611,6 +622,11 @@ def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_n # ---------- gh_push_branch ---------- def _build_push_branch(bindings: ToolBindings) -> HostTool[Any, Any]: def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + if bindings.review_mode: + msg = "refusing to push: PR review worktrees are read-only." + _audit(bindings, "gh_push_branch", args, error=msg) + _raise_command(msg) + _enforce_impl_authorization(bindings, "gh_push_branch", args, action="push branch") branch = str(args.get("branch") or bindings.workspace.branch) skip = bool(args.get("skip_checks", False)) # Same gate as gh_open_pr — formatter + check before bytes leave the @@ -648,6 +664,11 @@ def _build_push_branch(bindings: ToolBindings) -> HostTool[Any, Any]: # ---------- gh_open_pr ---------- def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]: def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + if bindings.review_mode: + msg = "refusing to open PR: PR review tasks are read-only." + _audit(bindings, "gh_open_pr", args, error=msg) + _raise_command(msg) + _enforce_impl_authorization(bindings, "gh_open_pr", args, action="open PR") title = args.get("title") body = args.get("body") if not isinstance(title, str) or not title.strip(): @@ -966,9 +987,334 @@ def _build_fetch_thread(bindings: ToolBindings) -> HostTool[Any, Any]: _PRIMARY_TYPES = ("bug", "enhancement", "question", "proposal", "documentation", "invalid", "duplicate") +_AUTO_PR_CLASSIFICATIONS = frozenset({"bug", "documentation"}) _PRIORITIES = ("prio:p0", "prio:p1", "prio:p2", "prio:p3") _FUNCTIONAL = ("agent", "tool", "tui", "cli", "prompting", "sdk", "auth", "setup", "ux", "providers") _PLATFORMS = ("platform:linux", "platform:macos", "platform:windows", "platform:wsl") +_PR_RANKS = ("review:p0", "review:p1", "review:p2", "review:p3") +_PR_TYPES = ("feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build") +_CLOSING_ISSUE_RE = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE) + + +def _enforce_impl_authorization( + bindings: ToolBindings, + tool_name: str, + args: Mapping[str, Any], + *, + action: str, +) -> None: + """Refuse first publish on issue classes that require maintainer authorization.""" + if bindings.impl_authorized: + return + row = bindings.db.get_issue(bindings.issue_key) + if row is not None: + if row.pr_number is not None: + return + classification = row.classification + if classification in _AUTO_PR_CLASSIFICATIONS: + return + else: + classification = None + classification_phrase = f"classified `{classification}`" if classification else "not classified" + msg = ( + f"refusing to {action}: issue #{bindings.issue.number} is {classification_phrase}; " + "a repo OWNER or allowlisted maintainer must @-mention you with an explicit go-ahead " + "before any branch/PR. Post your analysis with `gh_post_comment` and stop." + ) + _audit(bindings, tool_name, args, error=msg) + _raise_command(msg) + + +def _require_review_mode(bindings: ToolBindings, name: str, args: Mapping[str, Any]) -> None: + if bindings.review_mode: + return + msg = f"{name} is only available during incoming PR review tasks." + _audit(bindings, name, args, error=msg) + _raise_command(msg) + + +def _format_pr_file(file: PullRequestFileInfo) -> str: + return f"- `{file.path}` ({file.status}, +{file.additions}/-{file.deletions})" + + +def _build_fetch_pr(bindings: ToolBindings) -> HostTool[Any, Any]: + def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + _require_review_mode(bindings, "fetch_pr", args) + pr_number = bindings.default_comment_number + try: + pr = _run_coro(bindings.loop, bindings.github.get_pull_request(bindings.repo.full_name, pr_number)) + files = _run_coro(bindings.loop, bindings.github.list_pr_files(bindings.repo.full_name, pr_number)) + except GitHubError as exc: + _audit(bindings, "fetch_pr", args, error=str(exc)) + _raise_command(f"GitHub fetch failed: {exc.status} {exc.message}") + linked = tuple(sorted({int(match.group(1)) for match in _CLOSING_ISSUE_RE.finditer(pr.body)})) + lines = [ + f"# {pr.repo}#{pr.number} ({pr.state})", + f"title: {pr.title or '(untitled)'}", + f"author: @{pr.author}", + f"head: {pr.head_repo or pr.repo}:{pr.head_ref}", + f"base: {pr.base_ref}", + f"url: {pr.html_url}", + "", + "## Body", + pr.body.strip() or "(empty)", + "", + "## Linked issues", + ", ".join(f"#{n}" for n in linked) if linked else "(none found in PR body)", + "", + f"## Changed files ({len(files)})", + ] + lines.extend(_format_pr_file(file) for file in files) + rendered = "\n".join(lines) + _audit(bindings, "fetch_pr", args, result={"files": len(files), "linked_issues": list(linked)}) + return rendered + + return host_tool( + name="fetch_pr", + description=persona.host_tool_description("fetch_pr"), + parameters={"type": "object", "properties": {}, "additionalProperties": False}, + execute=execute, + ) + + +def _build_classify_pr(bindings: ToolBindings) -> HostTool[Any, Any]: + def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + _require_review_mode(bindings, "classify_pr", args) + rank = args.get("rank") + if rank not in _PR_RANKS: + msg = f"classify_pr 'rank' must be one of {_PR_RANKS}; got {rank!r}." + _audit(bindings, "classify_pr", args, error=msg) + _raise_command(msg) + pr_type = args.get("type") + if pr_type not in _PR_TYPES: + msg = f"classify_pr 'type' must be one of {_PR_TYPES}; got {pr_type!r}." + _audit(bindings, "classify_pr", args, error=msg) + _raise_command(msg) + rationale = args.get("rationale") + if not isinstance(rationale, str) or not rationale.strip(): + msg = "classify_pr requires a one-sentence 'rationale'." + _audit(bindings, "classify_pr", args, error=msg) + _raise_command(msg) + + labels: list[str] = ["triaged", str(rank), str(pr_type)] + for area in args.get("area") or (): + if isinstance(area, str) and area in _FUNCTIONAL: + labels.append(area) + provider = args.get("provider") + if isinstance(provider, str) and provider.strip() and provider.startswith("provider:"): + labels.append("providers") + labels.append(provider) + try: + applied = _run_coro( + bindings.loop, + bindings.github.add_issue_labels(bindings.repo.full_name, bindings.default_comment_number, labels), + ) + except GitHubError as exc: + _audit(bindings, "classify_pr", args, error=str(exc)) + _raise_command(f"GitHub rejected labels: {exc.status} {exc.message}") + bindings.db.set_issue_classification(bindings.issue_key, str(rank)) + _audit( + bindings, + "classify_pr", + args, + result={"rank": rank, "type": pr_type, "labels": list(applied), "rationale": rationale}, + ) + return f"classified PR as {rank}; labels applied: {', '.join(applied)}." + + return host_tool( + name="classify_pr", + description=persona.host_tool_description("classify_pr"), + parameters={ + "type": "object", + "properties": { + "rank": { + "type": "string", + "enum": list(_PR_RANKS), + "description": persona.host_tool_parameter_description("classify_pr", "rank"), + }, + "type": { + "type": "string", + "enum": list(_PR_TYPES), + "description": persona.host_tool_parameter_description("classify_pr", "type"), + }, + "area": { + "type": "array", + "items": {"type": "string", "enum": list(_FUNCTIONAL)}, + "description": persona.host_tool_parameter_description("classify_pr", "area"), + }, + "provider": { + "type": "string", + "description": persona.host_tool_parameter_description("classify_pr", "provider"), + }, + "rationale": { + "type": "string", + "description": persona.host_tool_parameter_description("classify_pr", "rationale"), + }, + }, + "required": ["rank", "type", "rationale"], + "additionalProperties": False, + }, + execute=execute, + ) + + +def _review_comment_to_payload(comment: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "path": comment.path, + "line": comment.line, + "side": comment.side, + "body": comment.body, + } + if comment.start_line is not None: + payload["start_line"] = comment.start_line + if comment.start_side is not None: + payload["start_side"] = comment.start_side + return payload + + +def _build_pr_review_comment(bindings: ToolBindings) -> HostTool[Any, Any]: + def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + _require_review_mode(bindings, "pr_review_comment", args) + path = args.get("path") + line = args.get("line") + body = args.get("body") + if not isinstance(path, str) or not path.strip(): + msg = "pr_review_comment requires a non-empty 'path'." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + if not isinstance(line, int) or line <= 0: + msg = "pr_review_comment requires a positive integer 'line'." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + if not isinstance(body, str) or not body.strip(): + msg = "pr_review_comment requires a non-empty 'body'." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + side = str(args.get("side") or "RIGHT") + if side not in ("RIGHT", "LEFT"): + msg = "pr_review_comment 'side' must be RIGHT or LEFT." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + start_line = args.get("start_line") + if start_line is not None and (not isinstance(start_line, int) or start_line <= 0): + msg = "pr_review_comment 'start_line' must be a positive integer when provided." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + start_side_raw = args.get("start_side") + start_side = str(start_side_raw) if start_side_raw is not None else None + if start_side is not None and start_side not in ("RIGHT", "LEFT"): + msg = "pr_review_comment 'start_side' must be RIGHT or LEFT when provided." + _audit(bindings, "pr_review_comment", args, error=msg) + _raise_command(msg) + staged = bindings.db.stage_review_comment( + issue_key=bindings.issue_key, + path=path.strip(), + line=line, + side=side, + start_line=start_line, + start_side=start_side, + body=body.strip(), + ) + count = len(bindings.db.list_staged_review_comments(bindings.issue_key)) + _audit(bindings, "pr_review_comment", args, result={"id": staged.id, "staged": count}) + return f"staged review comment #{staged.id}; staged_count={count}" + + return host_tool( + name="pr_review_comment", + description=persona.host_tool_description("pr_review_comment"), + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": persona.host_tool_parameter_description("pr_review_comment", "path"), + }, + "line": { + "type": "integer", + "description": persona.host_tool_parameter_description("pr_review_comment", "line"), + }, + "body": { + "type": "string", + "description": persona.host_tool_parameter_description("pr_review_comment", "body"), + }, + "side": { + "type": "string", + "enum": ["RIGHT", "LEFT"], + "default": "RIGHT", + "description": persona.host_tool_parameter_description("pr_review_comment", "side"), + }, + "start_line": { + "type": "integer", + "description": persona.host_tool_parameter_description("pr_review_comment", "start_line"), + }, + "start_side": { + "type": "string", + "enum": ["RIGHT", "LEFT"], + "description": persona.host_tool_parameter_description("pr_review_comment", "start_side"), + }, + }, + "required": ["path", "line", "body"], + "additionalProperties": False, + }, + execute=execute, + ) + + +def _build_submit_pr_review(bindings: ToolBindings) -> HostTool[Any, Any]: + def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str: + _require_review_mode(bindings, "submit_pr_review", args) + body = args.get("body") + if not isinstance(body, str) or not body.strip(): + msg = "submit_pr_review requires a non-empty 'body'." + _audit(bindings, "submit_pr_review", args, error=msg) + _raise_command(msg) + staged = bindings.db.list_staged_review_comments(bindings.issue_key) + comments = [_review_comment_to_payload(comment) for comment in staged] + try: + review = _run_coro( + bindings.loop, + bindings.github.submit_pr_review( + repo=bindings.repo.full_name, + pr_number=bindings.default_comment_number, + body=body.strip(), + event="COMMENT", + comments=comments, + ), + ) + except GitHubError as exc: + _audit(bindings, "submit_pr_review", args, error=str(exc)) + _raise_command(f"GitHub rejected PR review: {exc.status} {exc.message}") + cleared = bindings.db.clear_staged_review_comments(bindings.issue_key) + _audit( + bindings, + "submit_pr_review", + args, + result={"review_id": review.id, "comments": len(comments), "cleared": cleared, "event": "COMMENT"}, + ) + return f"submitted PR review id={review.id}; comments={len(comments)}" + + return host_tool( + name="submit_pr_review", + description=persona.host_tool_description("submit_pr_review"), + parameters={ + "type": "object", + "properties": { + "body": { + "type": "string", + "description": persona.host_tool_parameter_description("submit_pr_review", "body"), + }, + "event": { + "type": "string", + "enum": ["COMMENT"], + "default": "COMMENT", + "description": persona.host_tool_parameter_description("submit_pr_review", "event"), + }, + }, + "required": ["body"], + "additionalProperties": False, + }, + execute=execute, + ) def _build_set_issue_labels(bindings: ToolBindings) -> HostTool[Any, Any]: @@ -1205,6 +1551,10 @@ def build(bindings: ToolBindings) -> tuple[HostTool[Any, Any], ...]: return ( _build_classify_issue(bindings), _build_set_issue_labels(bindings), + _build_fetch_pr(bindings), + _build_classify_pr(bindings), + _build_pr_review_comment(bindings), + _build_submit_pr_review(bindings), _build_post_comment(bindings), _build_push_branch(bindings), _build_open_pr(bindings), diff --git a/python/robomp/src/manual_triage.py b/python/robomp/src/manual_triage.py index ac6559bb2..b5a805ac2 100644 --- a/python/robomp/src/manual_triage.py +++ b/python/robomp/src/manual_triage.py @@ -53,9 +53,7 @@ def parse_issue_ref(ref: str) -> tuple[str, int]: cleaned = ref.strip() match = _ISSUE_REF.match(cleaned) or _ISSUE_URL.match(cleaned) if match is None: - raise InvalidIssueRef( - f"expected owner/repo#NN or https://github.com/owner/repo/issues/NN, got {ref!r}" - ) + raise InvalidIssueRef(f"expected owner/repo#NN or https://github.com/owner/repo/issues/NN, got {ref!r}") return f"{match.group('owner')}/{match.group('repo')}", int(match.group("number")) diff --git a/python/robomp/src/persona.py b/python/robomp/src/persona.py index 074a949c8..7119857ef 100644 --- a/python/robomp/src/persona.py +++ b/python/robomp/src/persona.py @@ -16,7 +16,7 @@ from importlib import resources from typing import Any from robomp.git_ops import DirtyState -from robomp.github_client import CommentInfo, IssueInfo, RepoInfo +from robomp.github_client import CommentInfo, IssueInfo, PullRequestInfo, RepoInfo from robomp.sandbox import Workspace _PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}") @@ -132,10 +132,18 @@ def system_append(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> return render(_load("system_append.md"), {"repo": repo, "issue": issue, "workspace": workspace}) +def system_append_pr_review(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str: + return render(_load("system_append_pr_review.md"), {"repo": repo, "issue": issue, "workspace": workspace}) + + def kickoff(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str: return render(_load("kickoff_issue.md"), {"repo": repo, "issue": issue, "workspace": workspace}) +def kickoff_pr_review(*, repo: RepoInfo, pr: PullRequestInfo, workspace: Workspace) -> str: + return render(_load("kickoff_pr_review.md"), {"repo": repo, "pr": pr, "workspace": workspace}) + + def resume_triage(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str: """Resume prompt for a `triage_issue` task whose omp session already exists.""" return render(_load("resume_triage.md"), {"repo": repo, "issue": issue, "workspace": workspace}) @@ -146,6 +154,11 @@ def completion_reminder(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspac return render(_load("completion_reminder.md"), {"repo": repo, "issue": issue, "workspace": workspace}) +def review_completion_reminder(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str: + """Reminder injected when a PR review turn ends before submission.""" + return render(_load("review_completion_reminder.md"), {"repo": repo, "issue": issue, "workspace": workspace}) + + def dirty_state_reminder( *, repo: RepoInfo, @@ -376,8 +389,11 @@ __all__ = [ "host_tool_parameter_description", "kickoff", "kickoff_directive", + "kickoff_pr_review", "render", "completion_reminder", + "review_completion_reminder", + "system_append_pr_review", "dirty_state_reminder", "resume_triage", "seed_phases", diff --git a/python/robomp/src/prompts/directive.md b/python/robomp/src/prompts/directive.md index 3cb134720..8e6620f46 100644 --- a/python/robomp/src/prompts/directive.md +++ b/python/robomp/src/prompts/directive.md @@ -24,7 +24,7 @@ Read the thread first — reviewer bots (e.g. `chatgpt-codex-connector`) often r Then branch on request type: -- **Code change** → commit on `{{workspace.branch}}`. NEVER open a second PR; push to this branch. `gh_push_branch` / `gh_open_pr` run `bun run fix` + `bun check` before contacting the remote — you do NOT. After pushing, reply with ONE `gh_post_comment` summarizing the fix, one line per concrete change. Directive bundles multiple issues (e.g. several inline review comments)? Address each and group them in the reply. +- **Code change** → commit on `{{workspace.branch}}`. NEVER open a second PR; push to this branch. `gh_push_branch` / `gh_open_pr` run `bun run fix` + `bun check` before contacting the remote — you do NOT. If these tools refuse on an enhancement/proposal because the directive author lacks implementation authority, reply with ONE `gh_post_comment` explaining that a repo OWNER or allowlisted maintainer must explicitly authorize implementation, then stop. After pushing, reply with ONE `gh_post_comment` summarizing the fix, one line per concrete change. Directive bundles multiple issues (e.g. several inline review comments)? Address each and group them in the reply. - **Question / clarification** → one `gh_post_comment`. No code change. - **Explicit stop / drop this** → one ack comment, then halt. - **Ambiguous** → exactly one clarifying question, then stop. NEVER guess. diff --git a/python/robomp/src/prompts/followup_comment.md b/python/robomp/src/prompts/followup_comment.md index 7a0a5f7da..322e64f65 100644 --- a/python/robomp/src/prompts/followup_comment.md +++ b/python/robomp/src/prompts/followup_comment.md @@ -17,7 +17,7 @@ Thread context: {{origin.description}}. PR state: `{{state.pr_status}}`. Decide what to do: - **New repro info?** Re-run via `repro_record`, then `gh_post_comment` with the outcome. -- **PR change requested?** Amend `{{workspace.branch}}` and push; NEVER open a second PR. Reply with a short `gh_post_comment` naming what changed. +- **PR change requested?** Amend `{{workspace.branch}}` and push only for an already-open PR / authorized implementation; NEVER open a second PR, and NEVER open the first PR for an unauthorized enhancement/proposal. Reply with a short `gh_post_comment` naming what changed. - **Confirmation or unrelated question?** Reply with one `gh_post_comment`. Leave code untouched. - **Bot author or no actionable content?** No-op. diff --git a/python/robomp/src/prompts/host_tools.toml b/python/robomp/src/prompts/host_tools.toml index 77b4af592..54b5203a7 100644 --- a/python/robomp/src/prompts/host_tools.toml +++ b/python/robomp/src/prompts/host_tools.toml @@ -1,3 +1,34 @@ +[fetch_pr] +description = "Fetch the inbound PR premise and changed-file list for review. Review-mode only." + +[classify_pr] +description = "Classify and rank an incoming PR, apply triaged/review labels, and persist the rank. Review-mode only." + +[classify_pr.parameters] +rank = "Required review verdict label: one of `review:p0..p3`." +type = "Exactly one PR type: feat, fix, docs, refactor, perf, test, chore, ci, or build." +area = "Zero or more functional labels. Unknown values are dropped silently; omit when none apply." +provider = "Only when provider-scoped; format `provider:`. Omit otherwise." +rationale = "One sentence explaining what the PR changes and why it earns this rank." + +[pr_review_comment] +description = "Stage one inline PR review comment in sqlite. Does not call GitHub until submit_pr_review. Review-mode only." + +[pr_review_comment.parameters] +path = "Changed file path to comment on." +line = "Line number in the PR diff." +body = "Markdown body for one concrete finding." +side = "`RIGHT` for added/changed lines, `LEFT` for removed lines. Defaults to `RIGHT`." +start_line = "Optional first line for a multi-line comment range." +start_side = "Optional side for start_line; `RIGHT` or `LEFT`." + +[submit_pr_review] +description = "Submit one GitHub PR review with all staged inline comments. Always posts event=`COMMENT`; never approves or requests changes. Review-mode only." + +[submit_pr_review.parameters] +body = "Markdown review summary. Required even when there are no inline comments." +event = "Ignored except for schema compatibility; the orchestrator always sends `COMMENT`." + [gh_post_comment] description = "Post a comment on the inbound thread (PR for PR conversations/reviews, originating issue otherwise). Pass `number` ONLY to post elsewhere." diff --git a/python/robomp/src/prompts/kickoff_pr_review.md b/python/robomp/src/prompts/kickoff_pr_review.md new file mode 100644 index 000000000..3d965cdc5 --- /dev/null +++ b/python/robomp/src/prompts/kickoff_pr_review.md @@ -0,0 +1,136 @@ +# Reviewing pull request {{repo.full_name}}#{{pr.number}} + +**Author:** @{{pr.author}} +**Head:** `{{pr.head_ref}}` from `{{pr.head_repo}}` → **Base:** `{{pr.base_ref}}` +**PR:** {{pr.html_url}} + +The PR's head is checked out in the worktree at cwd. This is a **read-only review**: +you classify, rank, and comment. You NEVER merge, close, approve, push, or edit the +PR's code. The maintainer decides what happens to the PR — your job is to make that +decision a one-glance call. + +Run two phases in order. Phase 1 is cheap and always happens; Phase 2 is the real review. + + +- **Read-only.** No `gh_push_branch`, no `gh_open_pr`, no commits, no `git push`. The only + side effects are `classify_pr`, `pr_review_comment`, `submit_pr_review`, and (if a + maintainer must decide something) one `gh_post_comment`. +- **Phase 1 before Phase 2.** `classify_pr` is the first side effect. Rank and tag before + you write a single inline comment. +- **One review, batched.** Stage every inline finding with `pr_review_comment`, then flush + them all in ONE `submit_pr_review`. NEVER post inline findings as standalone comments. +- **Evidence first.** Cite file + line + symbol. "This looks risky" is not a review; + "`foo()` at `x.ts:42` dereferences `cfg` before the null guard on line 40" is. +- **Stay in scope.** Review THIS diff. Do not demand unrelated refactors, re-architecture, + or features the PR never claimed to deliver. + + +# Phase 0 — orient + +1. **Read the premise.** Call `fetch_pr` for the title, body, and any linked issue + (`Fixes #N`). Understand what the PR *claims* to do before judging whether it does it. +2. **Read the diff.** Prefer `git diff origin/{{pr.base_ref}}...HEAD` for the full changed-file set. If + `origin/{{pr.base_ref}}` is not present locally, fall back to `fetch_pr`'s file list plus + targeted `read`/`search` on the changed files. Note size, number of files, and whether the + changes are coherent or a grab-bag. +3. **Check it isn't already done.** Skim `git log origin/{{repo.default_branch}}` and open + PRs for the same fix. Already landed or superseded → still review, but it ranks **P3** + and your summary says so with a pointer to the commit/PR. + +# Phase 1 — classify & rank + +Call **`classify_pr`** exactly once. It applies the `triaged` tag plus the labels below. + +## Rank — one of `review:p0` … `review:p3` + +Rank by **value × scope discipline × maintainer confidence**, weighted heavily by how +closely the PR follows repo conventions (see Conventions). Higher convention adherence +and tighter scope rank up; sprawl and sloppiness rank down. + +- **P0** — lgtm / must-fix / a truly incremental, nicely scoped change. Correct, follows + conventions, nothing blocking. The maintainer can merge on a glance. + *(e.g. a small root-cause bug fix with a regression test.)* +- **P1** — mergeable after a touch. Minor nits, or an architectural concern worth raising + before it merges. + *(e.g. the fix is right but ships a verbose hardcoded list, or a cleaner placement exists.)* +- **P2** — needs an explicit maintainer call. A feature addition, or anything that changes + default behaviour without fixing a break. Don't treat "small" as "safe". + *(e.g. flips a default, adds a setting, or changes an existing contract.)* +- **P3** — deprioritize. Badly scoped (grab-bag of unrelated edits), carries irrelevant + changes, a large implementation with no confirmed maintainer intent, broken/off-spec, + or already resolved/superseded. + *(e.g. a 200-file PR standing up a mechanism the repo already has.)* + +## Categories + +- **type** — exactly one: `feat` `fix` `docs` `refactor` `perf` `test` `chore` `ci` `build`. +- **area** — zero or more, reusing the issue taxonomy: `agent` `tool` `tui` `cli` + `prompting` `sdk` `auth` `setup` `ux` `providers`. +- **provider** — only when provider-scoped: `provider:` (adds `providers`). Never + speculative. +- **rationale** — one sentence: what the PR does and why it earns its rank. + +# Phase 2 — review the diff + +Read the changed files in detail — not just the diff hunks, the surrounding code they +touch. Review with the lens of someone who will own this code: + +- **Correctness** — does it do what the premise claims? Off-by-one, wrong branch, inverted + condition, mishandled async, swallowed errors. +- **Introduced bugs / regressions** — does the change break a path that worked? Null/empty + conflated with error? Resource left open? Concurrency or shared-mutable-state hazard + (a global singleton mutated across sessions is a hard blocker)? +- **Security / safety** — injection, unsanitized input, credential leakage, sandbox escape, + unbounded execution. +- **Breaking changes** — changed defaults, renamed/removed public API, altered output that + something downstream parses. +- **Test coverage** — does every new branch have a test that defends an observable + contract? Tautological or default-value-only tests don't count. +- **Conventions** — see below. A convention breach is a real finding, not a nit to wave + through. +- **Silent contract violations** — does it advertise behavior (validation, caching, + isolation) it doesn't actually implement? + +For each concrete finding, stage an inline comment: + +``` +pr_review_comment(path="src/foo.ts", line=42, body="...", side="RIGHT", start_line=optional) +``` + +- `line` is the line in the diff you're commenting on; `side="RIGHT"` for added/changed + lines (the default), `"LEFT"` for removed lines. `start_line` for a multi-line range. +- One finding per comment. Lead with severity: **blocking** (correctness/security/contract), + **should-fix** (conventions, missing tests, regressions), **nit** (style/naming — sparingly). +- Ask, don't assume: if intent is unclear, phrase it as a question on the line. + +When done, flush everything in one review: + +``` +submit_pr_review(body="", event="COMMENT") +``` + +- `event` is always `COMMENT`. You do NOT `APPROVE` or `REQUEST_CHANGES` — those gate the + merge, which is the maintainer's call. The rank label carries your recommendation. +- The `body` summary: 2–5 lines. The rank and why, the headline findings grouped, and any + open question the maintainer must answer. Thank the contributor. No emoji. +- If the diff is clean and you found nothing, still submit a review: a one-line "lgtm — + " body with no inline comments. A clean P0 deserves an explicit green light. + +# Conventions (the bar; see `AGENTS.md`) + +Adherence is a first-class ranking signal. Flag violations as findings: + +- `CHANGELOG.md` entry under `## [Unreleased]` in each touched package. +- No prompts built in code — prompts live in `.md` files, dynamic content via Handlebars. +- No dynamic / inline `import()`; top-level imports only. +- Bun APIs over `node:*` where Bun covers it; never shell out for things with an API. +- TUI text sanitized (tabs→spaces, truncate, shorten paths) on EVERY render path, errors included. +- `#private` fields; no TS access keywords on members; no `any`; no `ReturnType<>`; star barrel exports. +- Tests assert observable contracts, never `mock.module()`, full-suite-safe. +- **No default-behaviour changes without explicit maintainer sign-off** — this alone caps a PR at P2. + +# Tone + +Terse. Technical. Evidence first, opinion last. Cite files/symbols/commits in backticks, +not vibes. Mirror the contributor's vocabulary. No filler, no emoji. Always thank the +contributor — in the review body, regardless of rank. diff --git a/python/robomp/src/prompts/review_completion_reminder.md b/python/robomp/src/prompts/review_completion_reminder.md new file mode 100644 index 000000000..725b062a0 --- /dev/null +++ b/python/robomp/src/prompts/review_completion_reminder.md @@ -0,0 +1,14 @@ +You ended your turn before finishing the PR review. + +PR: {{repo.full_name}}#{{issue.number}} — {{issue.title}} +Review workspace: `{{workspace.branch}}` + +You already started the review, but you did NOT reach the terminal action. +The acceptable terminal actions for an incoming PR review are exactly one of: + +1. `submit_pr_review` — submit the batched review summary plus any staged inline comments. +2. `abort_task` — unrecoverable environment failure. + +Review the staged comments, your TodoList, and the prior tool calls, then continue from where you stopped. Do NOT re-classify unless the earlier classify call failed. Do NOT post standalone inline findings. If you already staged comments, call `submit_pr_review` now. If you found no inline issues, still call `submit_pr_review` with the summary-only verdict. + +You MUST end this turn by calling one of the two terminal tools listed above. diff --git a/python/robomp/src/prompts/system_append_pr_review.md b/python/robomp/src/prompts/system_append_pr_review.md new file mode 100644 index 000000000..34cbf2b46 --- /dev/null +++ b/python/robomp/src/prompts/system_append_pr_review.md @@ -0,0 +1,11 @@ +You are **robomp**, reviewing an incoming pull request on `{{repo.full_name}}`. + + +- **Read-only PR review.** Never edit files, commit, push, open a PR, approve, request changes, merge, or close. +- **Review tools only.** Side effects are limited to `classify_pr`, staged `pr_review_comment` calls, one `submit_pr_review(event="COMMENT")`, and at most one `gh_post_comment` when maintainer context is required. +- **No issue triage workflow.** Do not call `classify_issue`, `set_issue_labels`, `repro_record`, `gh_push_branch`, `gh_open_pr`, or `mark_unable_to_reproduce`. +- **Classify before review comments.** Call `fetch_pr`, inspect the diff, then call `classify_pr` before staging inline comments. +- **One batched review.** Stage inline findings in sqlite and flush once with `submit_pr_review`. Submit even when there are zero inline findings. + + +Review only the PR diff and surrounding code needed to judge it. Findings must cite concrete files, lines, symbols, and failure modes. No filler, no emoji. diff --git a/python/robomp/src/prompts/todo_phases.toml b/python/robomp/src/prompts/todo_phases.toml index 3ade224cd..660eb8039 100644 --- a/python/robomp/src/prompts/todo_phases.toml +++ b/python/robomp/src/prompts/todo_phases.toml @@ -12,6 +12,26 @@ tasks = [ "Bug: repro_record, fix, open PR. Else: one gh_post_comment, stop.", ] +[[review_pr]] +name = "Orient" +tasks = [ + "Call fetch_pr and read the changed files", + "Compare the PR head against its base", +] + +[[review_pr]] +name = "Classify" +tasks = [ + "Call classify_pr with rank, type, and areas", +] + +[[review_pr]] +name = "Review" +tasks = [ + "Stage inline findings with pr_review_comment", + "Call submit_pr_review once with the summary", +] + [[handle_comment]] name = "Follow up" tasks = [ diff --git a/python/robomp/src/proxy/server.py b/python/robomp/src/proxy/server.py index 1fe210c63..6246382ae 100644 --- a/python/robomp/src/proxy/server.py +++ b/python/robomp/src/proxy/server.py @@ -33,6 +33,9 @@ from robomp.git_ops import ( from robomp.git_ops import ( clone as git_clone, ) +from robomp.git_ops import ( + fetch_pr_head as git_fetch_pr_head, +) from robomp.git_ops import ( fetch_prune as git_fetch_prune, ) @@ -120,6 +123,35 @@ def _optional_str_list(value: Any, field: str) -> list[str] | None: return list(value) +def _require_review_comments(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if not isinstance(value, list): + raise HTTPException(400, "missing/invalid 'comments'") + comments: list[dict[str, Any]] = [] + for idx, item in enumerate(value): + if not isinstance(item, dict): + raise HTTPException(400, f"comments[{idx}] must be an object") + path = _require_str(item.get("path"), f"comments[{idx}].path") + line = _require_int(item.get("line"), f"comments[{idx}].line") + body = _require_str(item.get("body"), f"comments[{idx}].body") + side = str(item.get("side") or "RIGHT") + if side not in ("RIGHT", "LEFT"): + raise HTTPException(400, f"comments[{idx}].side must be RIGHT or LEFT") + comment: dict[str, Any] = {"path": path, "line": line, "side": side, "body": body} + start_line = item.get("start_line") + if start_line is not None: + comment["start_line"] = _require_int(start_line, f"comments[{idx}].start_line") + start_side = item.get("start_side") + if start_side is not None: + start_side_str = _require_str(start_side, f"comments[{idx}].start_side") + if start_side_str not in ("RIGHT", "LEFT"): + raise HTTPException(400, f"comments[{idx}].start_side must be RIGHT or LEFT") + comment["start_side"] = start_side_str + comments.append(comment) + return comments + + def _pool_dir(cfg: Settings, repo: str) -> Path: if "/" not in repo or repo.startswith("/") or ".." in repo.split("/"): raise HTTPException(400, f"invalid repo {repo!r}") @@ -346,6 +378,16 @@ def create_proxy_app(settings: Settings) -> FastAPI: return _gh_error_response(exc) return JSONResponse(_serialize(info)) + @app.get("/gh/v1/pr_files") + async def list_pr_files(request: Request, repo: str, pr_number: int) -> JSONResponse: + await _authenticate(request) + github: GitHubClient = request.app.state.github + try: + items = await github.list_pr_files(repo, pr_number) + except GitHubError as exc: + return _gh_error_response(exc) + return JSONResponse({"items": [_serialize(item) for item in items]}) + @app.get("/gh/v1/issues") async def list_issues(request: Request, repo: str, state: str = "open", limit: int = 30) -> JSONResponse: await _authenticate(request) @@ -467,6 +509,27 @@ def create_proxy_app(settings: Settings) -> FastAPI: return _gh_error_response(exc) return JSONResponse({"labels": list(applied)}) + @app.post("/gh/v1/submit_pr_review") + async def submit_pr_review(request: Request) -> JSONResponse: + data = await _json_body(request) + repo = _require_str(data.get("repo"), "repo") + pr_number = _require_int(data.get("pr_number"), "pr_number") + body = _require_str(data.get("body"), "body") + event = str(data.get("event") or "COMMENT") + comments = _require_review_comments(data.get("comments")) + github: GitHubClient = request.app.state.github + try: + review = await github.submit_pr_review( + repo=repo, + pr_number=pr_number, + body=body, + event=event, + comments=comments, + ) + except GitHubError as exc: + return _gh_error_response(exc) + return JSONResponse(_serialize(review)) + @app.post("/gh/v1/add_assignees") async def add_assignees(request: Request) -> JSONResponse: data = await _json_body(request) @@ -568,6 +631,18 @@ def create_proxy_app(settings: Settings) -> FastAPI: await _run_git_op(git_fetch_ref, target, ref, token=_resolve_token(settings)) return JSONResponse({"pool_dir": str(target)}) + @app.post("/gh/v1/git/fetch_pr_head") + async def git_fetch_pr_head_endpoint(request: Request) -> JSONResponse: + data = await _json_body(request) + repo = _require_str(data.get("repo"), "repo") + pr_number = _require_int(data.get("pr_number"), "pr_number") + target = _pool_dir(settings, repo) + try: + await _run_git_op(git_fetch_pr_head, target, pr_number, token=_resolve_token(settings)) + except GitCommandError as exc: + return _git_error_response(exc) + return JSONResponse({"pool_dir": str(target)}) + @app.post("/gh/v1/git/push") async def git_push_endpoint(request: Request) -> JSONResponse: data = await _json_body(request) diff --git a/python/robomp/src/proxy_client.py b/python/robomp/src/proxy_client.py index 5d8e8429b..ca539b5e9 100644 --- a/python/robomp/src/proxy_client.py +++ b/python/robomp/src/proxy_client.py @@ -25,6 +25,7 @@ from robomp.github_client import ( GitHubError, IssueInfo, IssueSummary, + PullRequestFileInfo, PullRequestInfo, PullRequestReviewInfo, ReactionInfo, @@ -169,6 +170,14 @@ class GitHubProxyClient: data = await self._request("GET", "/gh/v1/pull_request", params={"repo": repo, "number": number}) return _pr_from(data) + async def list_pr_files(self, repo: str, pr_number: int) -> list[PullRequestFileInfo]: + data = await self._request( + "GET", + "/gh/v1/pr_files", + params={"repo": repo, "pr_number": pr_number}, + ) + return [_pr_file_from(item) for item in (data.get("items") if isinstance(data, dict) else None) or []] + async def list_issues( self, repo: str, @@ -273,6 +282,28 @@ class GitHubProxyClient: ) return tuple(str(lbl) for lbl in (data.get("labels") if isinstance(data, dict) else None) or []) + async def submit_pr_review( + self, + *, + repo: str, + pr_number: int, + body: str, + event: str, + comments: list[Mapping[str, Any]], + ) -> PullRequestReviewInfo: + data = await self._request( + "POST", + "/gh/v1/submit_pr_review", + json_body={ + "repo": repo, + "pr_number": pr_number, + "body": body, + "event": event, + "comments": comments, + }, + ) + return _pr_review_from(data) + async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None: if not assignees: return @@ -360,6 +391,10 @@ class ProxyGitTransport: del pool_dir self._post("/gh/v1/git/fetch_ref", {"repo": repo, "ref": ref}) + def fetch_pr_head(self, *, repo: str, pool_dir: Path, pr_number: int) -> None: + del pool_dir + self._post("/gh/v1/git/fetch_pr_head", {"repo": repo, "pr_number": pr_number}) + def push_branch( self, *, @@ -477,6 +512,17 @@ def _pr_review_from(data: Any) -> PullRequestReviewInfo: ) +def _pr_file_from(data: Any) -> PullRequestFileInfo: + if not isinstance(data, dict): + raise GitHubError(500, "proxy returned malformed pr_file payload") + return PullRequestFileInfo( + path=str(data.get("path") or ""), + status=str(data.get("status") or ""), + additions=int(data.get("additions") or 0), + deletions=int(data.get("deletions") or 0), + ) + + def _pr_from(data: Any) -> PullRequestInfo: if not isinstance(data, dict): raise GitHubError(500, "proxy returned malformed pr payload") @@ -489,6 +535,8 @@ def _pr_from(data: Any) -> PullRequestInfo: state=str(data.get("state") or "open"), author=str(data.get("author") or ""), head_repo=str(data.get("head_repo") or ""), + title=str(data.get("title") or ""), + body=str(data.get("body") or ""), ) diff --git a/python/robomp/src/queue.py b/python/robomp/src/queue.py index 4b90a96d7..96ff5c1c2 100644 --- a/python/robomp/src/queue.py +++ b/python/robomp/src/queue.py @@ -374,6 +374,18 @@ class WorkerPool: attempts=row.attempts, slot_uid=slot_uid, ) + elif event == "pull_request" and action in ("opened", "reopened", "ready_for_review"): + await tasks.review_pr( + settings=self.settings, + db=self.db, + github=self.github, + sandbox=self.sandbox, + git_transport=self.git_transport, + payload=row.payload, + delivery_id=row.delivery_id, + attempts=row.attempts, + slot_uid=slot_uid, + ) elif event == "pull_request_review_comment" and action == "created": await tasks.handle_review( settings=self.settings, @@ -395,12 +407,14 @@ class WorkerPool: target_state="closed", ) elif event == "pull_request" and action == "closed": + pr = row.payload.get("pull_request") or {} + target_state = "merged" if bool(pr.get("merged")) else "closed" await tasks.cleanup_workspace( settings=self.settings, db=self.db, sandbox=self.sandbox, payload=row.payload, - target_state="merged", + target_state=target_state, ) else: log.info("no-op dispatch", extra={"event": event, "action": action}) diff --git a/python/robomp/src/sandbox.py b/python/robomp/src/sandbox.py index 15a671b0e..4e15b28e7 100644 --- a/python/robomp/src/sandbox.py +++ b/python/robomp/src/sandbox.py @@ -59,6 +59,9 @@ from robomp.git_ops import ( from robomp.git_ops import ( clone as git_clone, ) +from robomp.git_ops import ( + fetch_pr_head as git_fetch_pr_head, +) from robomp.git_ops import ( fetch_prune as git_fetch_prune, ) @@ -231,6 +234,10 @@ class GitTransport(Protocol): """Best-effort `git fetch origin ` to ensure the base branch is local.""" ... + def fetch_pr_head(self, *, repo: str, pool_dir: Path, pr_number: int) -> None: + """Fetch `refs/pull//head` into FETCH_HEAD for detached PR review checkouts.""" + ... + def push_branch( self, *, @@ -270,6 +277,10 @@ class LocalGitTransport: del repo git_fetch_ref(pool_dir, ref, token=self._token) + def fetch_pr_head(self, *, repo: str, pool_dir: Path, pr_number: int) -> None: + del repo + git_fetch_pr_head(pool_dir, pr_number, token=self._token) + def push_branch( self, *, @@ -679,11 +690,14 @@ class SandboxManager: clone_url: str, default_branch: str, existing_branch: str | None = None, + pr_head: int | None = None, author_name: str, author_email: str, slot_uid: int | None = None, ) -> Workspace: """Create or resume a per-issue worktree.""" + if pr_head is not None and existing_branch is not None: + raise ValueError("ensure_workspace accepts either pr_head or existing_branch, not both") pool = self.ensure_clone(repo=repo, clone_url=clone_url, default_branch=default_branch) ws_root = self.workspace_root(repo, number) repo_dir = ws_root / "repo" @@ -693,10 +707,15 @@ class SandboxManager: for path in (ws_root, session_dir, context_dir, context_dir / "repro", artifacts_dir): path.mkdir(parents=True, exist_ok=True) - branch = existing_branch or make_branch( - issue_number=number, - title=title, - seed=f"{repo}#{number}", + branch = ( + f"review/pr-{pr_head}" + if pr_head is not None + else existing_branch + or make_branch( + issue_number=number, + title=title, + seed=f"{repo}#{number}", + ) ) repo_exists = (repo_dir / ".git").exists() @@ -713,35 +732,39 @@ class SandboxManager: _chown_workspace(ws_root, slot_uid) workspace_prepared = True if not repo_exists: - # Make sure the requested start point exists locally (best-effort). - # For follow-ups on an existing PR, `existing_branch` is the remote - # head branch we need to amend; starting from default would silently - # lose the PR's current commits if the local pool branch is absent. - self.transport.fetch_base_ref(repo=repo, pool_dir=pool, ref=existing_branch or default_branch) - check = _safe_run(["git", "rev-parse", "--verify", f"refs/heads/{branch}"], cwd=pool) - if check.returncode == 0: - _run(["git", "worktree", "add", str(repo_dir), branch], cwd=pool) + if pr_head is not None: + self.transport.fetch_pr_head(repo=repo, pool_dir=pool, pr_number=pr_head) + _run(["git", "worktree", "add", "--detach", str(repo_dir), "FETCH_HEAD"], cwd=pool) else: - start_point = f"origin/{default_branch}" - if existing_branch: - remote = _safe_run( - ["git", "rev-parse", "--verify", f"refs/remotes/origin/{existing_branch}"], + # Make sure the requested start point exists locally (best-effort). + # For follow-ups on an existing PR, `existing_branch` is the remote + # head branch we need to amend; starting from default would silently + # lose the PR's current commits if the local pool branch is absent. + self.transport.fetch_base_ref(repo=repo, pool_dir=pool, ref=existing_branch or default_branch) + check = _safe_run(["git", "rev-parse", "--verify", f"refs/heads/{branch}"], cwd=pool) + if check.returncode == 0: + _run(["git", "worktree", "add", str(repo_dir), branch], cwd=pool) + else: + start_point = f"origin/{default_branch}" + if existing_branch: + remote = _safe_run( + ["git", "rev-parse", "--verify", f"refs/remotes/origin/{existing_branch}"], + cwd=pool, + ) + if remote.returncode == 0: + start_point = f"origin/{existing_branch}" + _run( + [ + "git", + "worktree", + "add", + "-b", + branch, + str(repo_dir), + start_point, + ], cwd=pool, ) - if remote.returncode == 0: - start_point = f"origin/{existing_branch}" - _run( - [ - "git", - "worktree", - "add", - "-b", - branch, - str(repo_dir), - start_point, - ], - cwd=pool, - ) else: slot_git_env = _git_env_for_repo(repo_dir) current = _safe_run( diff --git a/python/robomp/src/server.py b/python/robomp/src/server.py index 2cab9702f..a8a6c24e9 100644 --- a/python/robomp/src/server.py +++ b/python/robomp/src/server.py @@ -340,6 +340,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: bot_login=cfg.bot_login, maintainers=cfg.maintainer_logins, reviewer_bots=cfg.reviewer_bots, + pr_review_enabled=cfg.pr_review_enabled, resolve_issue_from_pr=_resolve, ) @@ -378,6 +379,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: "body": decision.directive_body, "author": decision.directive_author, "pragmas": [list(item) for item in decision.directive_pragmas], + "authorizes_impl": decision.directive_authorizes_impl, } if not decision.should_queue: diff --git a/python/robomp/src/tasks.py b/python/robomp/src/tasks.py index 2b9ba5739..d78ff4978 100644 --- a/python/robomp/src/tasks.py +++ b/python/robomp/src/tasks.py @@ -54,7 +54,12 @@ def _directive_from_payload(payload: Mapping[str, Any]) -> DirectiveInfo | None: k, v = entry if isinstance(k, str) and isinstance(v, str): pragmas.append((k, v)) - return DirectiveInfo(body=body, author=author, pragmas=tuple(pragmas)) + return DirectiveInfo( + body=body, + author=author, + pragmas=tuple(pragmas), + authorizes_impl=bool(raw.get("authorizes_impl")), + ) async def _fetch_thread( @@ -290,6 +295,83 @@ async def triage_issue( await run_task(task_kind="triage_issue", inputs=inputs) +async def review_pr( + *, + settings: Settings, + db: Database, + github: GitHubBackend, + sandbox: SandboxManager, + git_transport: GitTransport, + payload: Mapping[str, Any], + delivery_id: str, + attempts: int = 0, + slot_uid: int | None = None, +) -> None: + pr_node = payload.get("pull_request") or {} + pr_number = int(pr_node.get("number") or 0) + repo_payload = payload.get("repository") or {} + repo_full = str(repo_payload.get("full_name") or "") + if pr_number <= 0 or not repo_full: + log.info("skip: review_pr missing repo/number") + return + try: + repo = await github.get_repo(repo_full) + issue = await github.get_issue(repo_full, pr_number) + pr = await github.get_pull_request(repo_full, pr_number) + except GitHubError as exc: + log.warning("review_pr fetch failed", extra={"repo": repo_full, "pr": pr_number, "err": str(exc)}) + return + + labels = {label.lower() for label in issue.labels} + key = issue_key(repo.full_name, pr_number) + review_labeled = "triaged" in labels or any(label.startswith("review:") for label in labels) + if db.has_successful_tool_call(key, "submit_pr_review"): + log.info("skip: PR review already submitted", extra={"repo": repo_full, "pr": pr_number}) + return + if review_labeled: + log.info( + "review labels present without submitted review; retrying", + extra={"repo": repo_full, "pr": pr_number, "labels": sorted(labels)}, + ) + + db.upsert_issue(key=key, repo=repo.full_name, number=pr_number, state="reviewing", pr_number=pr_number) + workspace = sandbox.ensure_workspace( + repo=repo.full_name, + number=pr_number, + title=issue.title, + clone_url=repo.clone_url, + default_branch=repo.default_branch, + pr_head=pr_number, + author_name=settings.resolved_author_name, + author_email=settings.git_author_email, + slot_uid=slot_uid, + ) + db.upsert_issue( + key=key, + repo=repo.full_name, + number=pr_number, + state="reviewing", + branch=workspace.branch, + session_dir=str(workspace.session_dir), + pr_number=pr_number, + ) + inputs = TaskInputs( + settings=settings, + db=db, + github=github, + git_transport=git_transport, + repo=repo, + issue=issue, + workspace=workspace, + delivery_id=delivery_id, + attempts=attempts, + slot_uid=slot_uid, + natives_cache=sandbox.natives_cache, + ) + await run_task(task_kind="review_pr", inputs=inputs, pr_number=pr_number, pr=pr) + return + + async def handle_comment( *, settings: Settings, @@ -566,6 +648,9 @@ async def handle_pr_conversation( if pr_info is None or not _can_handle_pr_directly(settings=settings, repo_full=repo_full, pr=pr_info): return directive = _directive_from_payload(payload) + if issue_row is not None and issue_row.state == "reviewing": + log.info("skip: incoming PR conversation unsupported", extra={"key": issue_row.key, "pr": pr_number}) + return if issue_row is not None and issue_row.state in ("merged", "closed", "abandoned"): if directive is None: log.info("skip: pr-conversation on finalized issue", extra={"key": issue_row.key, "state": issue_row.state}) @@ -716,5 +801,6 @@ __all__ = [ "handle_comment", "handle_pr_conversation", "handle_review", + "review_pr", "triage_issue", ] diff --git a/python/robomp/src/worker.py b/python/robomp/src/worker.py index ea8326ebc..6c2e02fa7 100644 --- a/python/robomp/src/worker.py +++ b/python/robomp/src/worker.py @@ -35,7 +35,7 @@ from robomp.config import Settings from robomp.db import Database, issue_key from robomp.git_ops import DirtyState, inspect_dirty_state from robomp.github_backend import GitHubBackend -from robomp.github_client import CommentInfo, IssueInfo, RepoInfo +from robomp.github_client import CommentInfo, IssueInfo, PullRequestInfo, RepoInfo from robomp.host_tools import AbortController, ToolBindings, _git_identity_env from robomp.natives_cache import NativesCache from robomp.natives_cache import compute_key as natives_compute_key @@ -86,6 +86,7 @@ class DirectiveInfo: author: str thread: tuple[ThreadMessage, ...] = () pragmas: tuple[tuple[str, str], ...] = () + authorizes_impl: bool = False def _resolve_pragma_overrides( @@ -190,6 +191,7 @@ def _build_extra_env(settings: Settings) -> dict[str, str]: _TERMINAL_TRIAGE_TOOLS: frozenset[str] = frozenset({"gh_open_pr", "mark_unable_to_reproduce", "abort_task"}) +_TERMINAL_REVIEW_TOOLS: frozenset[str] = frozenset({"submit_pr_review", "abort_task"}) _PR_REQUIRING_CLASSIFICATIONS: frozenset[str] = frozenset({"bug", "documentation"}) @@ -200,16 +202,13 @@ def _needs_completion_reminder( bindings: ToolBindings, tools_called: set[str], ) -> bool: - """True iff a `triage_issue` turn ended before reaching a terminal tool. - - Only enforced for `bug` / `documentation` classifications — `question`, - `enhancement`, `proposal`, `invalid`, `duplicate` terminate on a single - `gh_post_comment` which we can't reliably distinguish from a preamble. - """ - if task_kind != "triage_issue": - return False + """True iff a task turn ended before reaching its terminal tool.""" if bindings.abort is not None and bindings.abort.triggered: return False + if task_kind == "review_pr": + return not (tools_called & _TERMINAL_REVIEW_TOOLS) + if task_kind != "triage_issue": + return False row = inputs.db.get_issue(bindings.issue_key) if row is None or row.classification not in _PR_REQUIRING_CLASSIFICATIONS: return False @@ -278,8 +277,9 @@ def _drive_turn( needs_completion = _needs_completion_reminder( task_kind=task_kind, inputs=inputs, bindings=bindings, tools_called=tools_called ) - dirty: DirtyState | None = None if not needs_completion: + if task_kind == "review_pr": + break dirty = _probe_workspace_dirty(inputs.workspace, inputs.slot_uid) if not dirty.is_dirty: break @@ -294,7 +294,11 @@ def _drive_turn( "max": max_reminders, }, ) - reminder = persona.completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + reminder = ( + persona.review_completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + if task_kind == "review_pr" + else persona.completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + ) else: assert dirty is not None log.warning( @@ -331,7 +335,7 @@ def _drive_turn( "tools_called": sorted(tools_called), }, ) - if reminders_used: + if reminders_used and task_kind != "review_pr": final_dirty = _probe_workspace_dirty(inputs.workspace, inputs.slot_uid) if final_dirty.is_dirty: log.warning( @@ -368,6 +372,7 @@ def _build_prompt( comment: CommentInfo | None, pr_number: int | None, review_payload: dict[str, Any] | None, + pr: PullRequestInfo | None = None, directive: DirectiveInfo | None = None, thread: tuple[ThreadMessage, ...] = (), resuming: bool = False, @@ -383,6 +388,9 @@ def _build_prompt( directive=directive, ) return persona.kickoff(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + if task_kind == "review_pr": + assert pr is not None + return persona.kickoff_pr_review(repo=inputs.repo, pr=pr, workspace=inputs.workspace) if task_kind == "handle_comment": assert comment is not None issue_row = inputs.db.get_issue(issue_key(inputs.repo.full_name, inputs.issue.number)) @@ -503,6 +511,11 @@ def _run_rpc_blocking( }, ) inputs.db.set_event_model(inputs.delivery_id, chosen_model) + append_system_prompt = ( + persona.system_append_pr_review(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + if task_kind == "review_pr" + else persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace) + ) with RpcClient( executable=settings.omp_command, @@ -514,7 +527,7 @@ def _run_rpc_blocking( model=chosen_model, provider=settings.provider, thinking=chosen_thinking if chosen_thinking != "off" else None, - append_system_prompt=persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace), + append_system_prompt=append_system_prompt, custom_tools=host_tools.build(bindings), request_timeout=settings.request_timeout_seconds, startup_timeout=60.0, @@ -557,13 +570,12 @@ def _run_rpc_blocking( phases = persona.seed_phases(task_kind) if phases: try: - if task_kind == "triage_issue" and not resuming: - # Fresh triage: seed the full plan. + if task_kind in ("triage_issue", "review_pr") and not resuming: + # Fresh kickoff tasks seed the full plan. client.set_todos(phases) - elif task_kind == "triage_issue": - # Resumed triage: prior phases are intact in the - # JSONL transcript — re-seeding would clobber any - # in-progress task statuses. Trust the loaded state. + elif task_kind in ("triage_issue", "review_pr"): + # Resumed kickoff tasks keep prior todo state from the + # JSONL transcript; re-seeding would clobber progress. log.info( "set_todos skipped (resume)", extra={"issue": bindings.issue_key, "task": task_kind}, @@ -653,10 +665,12 @@ async def run_task( comment: CommentInfo | None = None, pr_number: int | None = None, review_payload: dict[str, Any] | None = None, + pr: PullRequestInfo | None = None, directive: DirectiveInfo | None = None, thread: tuple[ThreadMessage, ...] = (), ) -> str | None: """Async wrapper that runs the synchronous RPC driver on a worker thread.""" + review_mode = task_kind == "review_pr" or inputs.workspace.branch.startswith("review/pr-") loop = asyncio.get_running_loop() bindings = ToolBindings( db=inputs.db, @@ -671,6 +685,8 @@ async def run_task( author_email=inputs.settings.git_author_email, inbound_thread_number=pr_number, inbound_is_pr=pr_number is not None, + review_mode=review_mode, + impl_authorized=bool(directive is not None and directive.authorizes_impl), slot_uid=inputs.slot_uid, abort=AbortController(), ) @@ -681,6 +697,7 @@ async def run_task( comment=comment, pr_number=pr_number, review_payload=review_payload, + pr=pr, directive=directive, thread=thread, resuming=resuming, diff --git a/python/robomp/tests/test_db.py b/python/robomp/tests/test_db.py index b46bf54cd..55aa05584 100644 --- a/python/robomp/tests/test_db.py +++ b/python/robomp/tests/test_db.py @@ -204,6 +204,36 @@ def test_log_tool_call(db: Database) -> None: assert row_id > 0 +def test_pr_review_comment_staging_round_trip(db: Database) -> None: + first = db.stage_review_comment( + issue_key="octo/widget#9", + path="src/app.py", + line=12, + side="RIGHT", + start_line=10, + start_side="RIGHT", + body="blocking finding", + ) + db.stage_review_comment( + issue_key="octo/widget#9", + path="src/other.py", + line=3, + body="nit", + ) + db.stage_review_comment(issue_key="octo/widget#10", path="x.py", line=1, body="other") + + rows = db.list_staged_review_comments("octo/widget#9") + assert [row.id for row in rows] == [first.id, first.id + 1] + assert rows[0].path == "src/app.py" + assert rows[0].start_line == 10 + assert rows[0].start_side == "RIGHT" + assert rows[1].side == "RIGHT" + + assert db.clear_staged_review_comments("octo/widget#9") == 2 + assert db.list_staged_review_comments("octo/widget#9") == [] + assert len(db.list_staged_review_comments("octo/widget#10")) == 1 + + def test_processed_issue_keys_returns_only_known(db: Database) -> None: db.upsert_issue(key=issue_key("octo/widget", 1), repo="octo/widget", number=1, state="new") db.upsert_issue(key=issue_key("octo/widget", 2), repo="octo/widget", number=2, state="reproducing") diff --git a/python/robomp/tests/test_github_client.py b/python/robomp/tests/test_github_client.py index 9da1d4ce4..c055eee72 100644 --- a/python/robomp/tests/test_github_client.py +++ b/python/robomp/tests/test_github_client.py @@ -109,6 +109,117 @@ def test_get_pull_request_parses_head_repo_and_author() -> None: assert pr.author == "robomp-bot" +def test_get_pull_request_parses_title_and_body() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/repos/octo/widget/pulls/9" + return httpx.Response( + 200, + json={ + "number": 9, + "html_url": "https://github.com/octo/widget/pull/9", + "title": "Fix crash", + "body": "Fixes #1", + "head": {"ref": "fix", "repo": {"full_name": "fork/widget"}}, + "base": {"ref": "main"}, + "state": "open", + "user": {"login": "alice"}, + }, + ) + + client = GitHubClient("tok", transport=httpx.MockTransport(handler)) + pr = _run_async(client.get_pull_request("octo/widget", 9)) + assert pr.title == "Fix crash" + assert pr.body == "Fixes #1" + + +def test_list_pr_files_parses_changed_file_summary() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/repos/octo/widget/pulls/9/files" + assert request.url.params.get("per_page") == "100" + return httpx.Response( + 200, + json=[{"filename": "src/app.py", "status": "modified", "additions": 5, "deletions": 2}], + ) + + client = GitHubClient("tok", transport=httpx.MockTransport(handler)) + files = _run_async(client.list_pr_files("octo/widget", 9)) + assert len(files) == 1 + assert files[0].path == "src/app.py" + assert files[0].additions == 5 + assert files[0].deletions == 2 + + +def test_list_pr_files_paginates_past_first_page() -> None: + seen_pages: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/repos/octo/widget/pulls/9/files" + page = request.url.params.get("page") + seen_pages.append(page) + if page == "1": + return httpx.Response( + 200, + json=[ + { + "filename": f"src/file-{idx}.py", + "status": "modified", + "additions": 1, + "deletions": 0, + } + for idx in range(100) + ], + ) + assert page == "2" + return httpx.Response( + 200, + json=[{"filename": "src/final.py", "status": "added", "additions": 2, "deletions": 0}], + ) + + client = GitHubClient("tok", transport=httpx.MockTransport(handler)) + files = _run_async(client.list_pr_files("octo/widget", 9)) + assert seen_pages == ["1", "2"] + assert len(files) == 101 + assert files[-1].path == "src/final.py" + + +def test_submit_pr_review_posts_comment_event_and_inline_comments() -> None: + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + import json + + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": 44, + "user": {"login": "robomp-bot"}, + "body": "summary", + "state": "COMMENTED", + "submitted_at": "t", + }, + ) + + client = GitHubClient("tok", transport=httpx.MockTransport(handler)) + review = _run_async( + client.submit_pr_review( + repo="octo/widget", + pr_number=9, + body="summary", + event="COMMENT", + comments=[{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}], + ) + ) + assert review.id == 44 + assert captured["path"] == "/repos/octo/widget/pulls/9/reviews" + assert captured["body"] == { + "body": "summary", + "event": "COMMENT", + "comments": [{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}], + } + + def test_204_no_content_returns_none() -> None: transport = httpx.MockTransport(lambda r: httpx.Response(204)) client = GitHubClient("tok", transport=transport) diff --git a/python/robomp/tests/test_github_events.py b/python/robomp/tests/test_github_events.py index 0d32bba2e..6acffbb09 100644 --- a/python/robomp/tests/test_github_events.py +++ b/python/robomp/tests/test_github_events.py @@ -5,6 +5,7 @@ import hmac from robomp.github_events import ( extract_mention, + is_implementation_authorizer, is_maintainer, rate_limit_cap, route, @@ -131,7 +132,7 @@ def test_route_pr_conversation_uses_handle_pr_conversation() -> None: { "action": "created", "comment": {"user": {"login": "alice"}, "body": "looks good"}, - "issue": {"number": 9, "pull_request": {"url": "x"}}, + "issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}}, "repository": {"full_name": "octo/widget"}, }, allowlist=ALLOWLIST, @@ -155,7 +156,7 @@ def test_route_pr_conversation_uses_resolver_for_inflight_key() -> None: { "action": "created", "comment": {"user": {"login": "alice"}, "body": "looks good"}, - "issue": {"number": 9, "pull_request": {"url": "x"}}, + "issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}}, "repository": {"full_name": "octo/widget"}, }, allowlist=ALLOWLIST, @@ -175,7 +176,7 @@ def test_route_pr_conversation_falls_back_to_pr_key_when_resolver_misses() -> No { "action": "created", "comment": {"user": {"login": "alice"}, "body": "hi"}, - "issue": {"number": 9, "pull_request": {"url": "x"}}, + "issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}}, "repository": {"full_name": "octo/widget"}, }, allowlist=ALLOWLIST, @@ -188,6 +189,98 @@ def test_route_pr_conversation_falls_back_to_pr_key_when_resolver_misses() -> No assert decision.issue_key == "octo/widget#9" +def test_route_incoming_pr_opened_queues_review_pr() -> None: + decision = route( + "pull_request", + { + "action": "opened", + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "author_association": "CONTRIBUTOR", + }, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert decision.should_queue + assert decision.task == "review_pr" + assert decision.issue_key == "octo/widget#9" + assert decision.submitter == "alice" + assert decision.association == "CONTRIBUTOR" + + +def test_route_incoming_pr_opened_skips_draft_bot_and_disabled() -> None: + payload = { + "action": "opened", + "pull_request": {"number": 9, "draft": True, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + } + assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue + + payload["pull_request"]["draft"] = False # type: ignore[index] + payload["pull_request"]["user"] = {"login": BOT, "type": "Bot"} # type: ignore[index] + assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue + + payload["pull_request"]["user"] = {"login": "alice", "type": "User"} # type: ignore[index] + disabled = route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT, pr_review_enabled=False) + assert not disabled.should_queue + assert "disabled" in disabled.reason + + +def test_route_pull_request_synchronize_stays_skipped() -> None: + decision = route( + "pull_request", + { + "action": "synchronize", + "pull_request": {"number": 9, "user": {"login": "alice"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert not decision.should_queue + + +def test_route_incoming_pr_comment_skips() -> None: + decision = route( + "issue_comment", + { + "action": "created", + "comment": {"user": {"login": "alice"}, "body": "ping"}, + "issue": {"number": 9, "user": {"login": "contributor"}, "pull_request": {"url": "x"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert not decision.should_queue + assert "incoming PR comments ignored" == decision.reason + + +def test_route_incoming_pr_comment_with_maintainer_mention_still_skips() -> None: + decision = route( + "issue_comment", + { + "action": "created", + "comment": { + "user": {"login": "can1357"}, + "author_association": "OWNER", + "body": "@robomp-bot please re-review", + }, + "issue": {"number": 9, "user": {"login": "contributor"}, "pull_request": {"url": "x"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert not decision.should_queue + assert decision.issue_key == "octo/widget#9" + assert decision.reason == "incoming PR comments ignored" + + def test_route_review_only_for_bot_authored_pr() -> None: decision = route( "pull_request_review_comment", @@ -238,10 +331,10 @@ def test_route_review_comment_falls_back_to_pr_key_when_resolver_misses() -> Non assert decision.issue_key == "octo/widget#9" -def test_route_pr_closed_only_when_merged_by_bot() -> None: +def test_route_pr_closed_cleans_up_any_tracked_pr() -> None: payload = { "action": "closed", - "pull_request": {"number": 9, "user": {"login": BOT}, "merged": True}, + "pull_request": {"number": 9, "user": {"login": "alice"}, "merged": False}, "repository": {"full_name": "octo/widget"}, } decision = route( @@ -254,21 +347,21 @@ def test_route_pr_closed_only_when_merged_by_bot() -> None: assert decision.should_queue assert decision.task == "cleanup_workspace" assert decision.issue_key == "octo/widget#42" + assert decision.reason == "pull_request.closed" - fallback = route( + payload["pull_request"]["merged"] = True # type: ignore[index] + merged = route( "pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT, resolve_issue_from_pr=lambda _r, _n: None, ) - assert fallback.should_queue - assert fallback.task == "cleanup_workspace" - assert fallback.issue_key == "octo/widget#9" - assert fallback.submitter is None - - payload["pull_request"]["merged"] = False # type: ignore[index] - assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue + assert merged.should_queue + assert merged.task == "cleanup_workspace" + assert merged.issue_key == "octo/widget#9" + assert merged.reason == "pull_request.merged" + assert merged.submitter is None def test_route_skips_pull_request_issues_event() -> None: @@ -453,6 +546,17 @@ def test_is_maintainer_rejects_contributor_and_none() -> None: assert is_maintainer(None, "OWNER", maintainers=frozenset()) # association still wins +def test_is_implementation_authorizer_accepts_allowlist_and_owner() -> None: + assert is_implementation_authorizer("can1357", None, maintainers=frozenset({"can1357"})) + assert is_implementation_authorizer("Can1357", "NONE", maintainers=frozenset({"can1357"})) + assert is_implementation_authorizer("stranger", "OWNER", maintainers=frozenset()) + + +def test_is_implementation_authorizer_rejects_non_owner_associations() -> None: + for assoc in ("MEMBER", "COLLABORATOR", "NONE", "CONTRIBUTOR", None): + assert not is_implementation_authorizer("stranger", assoc, maintainers=frozenset()), assoc + + def test_route_directive_set_on_issue_comment_when_owner_mentions_bot() -> None: decision = route( "issue_comment", @@ -473,6 +577,7 @@ def test_route_directive_set_on_issue_comment_when_owner_mentions_bot() -> None: assert decision.directive is True assert decision.directive_body == "please refactor X" assert decision.directive_author == "can1357" + assert decision.directive_authorizes_impl is True def test_route_directive_set_when_login_in_maintainers_list() -> None: @@ -495,6 +600,28 @@ def test_route_directive_set_when_login_in_maintainers_list() -> None: assert decision.directive is True assert decision.directive_body == "do it" assert decision.directive_author == "can1357" + assert decision.directive_authorizes_impl is True + + +def test_route_directive_from_collaborator_does_not_authorize_impl() -> None: + decision = route( + "issue_comment", + { + "action": "created", + "comment": { + "user": {"login": "oldschoola"}, + "author_association": "COLLABORATOR", + "body": "@robomp-bot go ahead with the plan", + }, + "issue": {"number": 9}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert decision.directive is True + assert decision.directive_body == "go ahead with the plan" + assert decision.directive_authorizes_impl is False def test_route_directive_unset_for_random_user_even_with_mention() -> None: @@ -537,7 +664,7 @@ def test_route_directive_unset_for_maintainer_without_mention() -> None: assert decision.directive is False -def test_route_directive_set_on_pr_conversation() -> None: +def test_route_directive_on_incoming_pr_conversation_is_ignored() -> None: decision = route( "issue_comment", { @@ -554,10 +681,8 @@ def test_route_directive_set_on_pr_conversation() -> None: bot_login=BOT, resolve_issue_from_pr=lambda _r, _n: "octo/widget#42", ) - assert decision.should_queue - assert decision.task == "handle_pr_conversation" - assert decision.directive is True - assert decision.directive_body == "change the indentation in foo.py" + assert not decision.should_queue + assert decision.reason == "incoming PR comments ignored" def test_route_directive_set_on_review_comment() -> None: @@ -586,7 +711,7 @@ def test_route_directive_set_on_review_comment() -> None: # ---------- reviewer bots ---------- -def test_route_reviewer_bot_comment_is_directive_without_mention() -> None: +def test_route_reviewer_bot_comment_on_incoming_pr_is_ignored() -> None: decision = route( "issue_comment", { @@ -603,11 +728,8 @@ def test_route_reviewer_bot_comment_is_directive_without_mention() -> None: reviewer_bots=frozenset({"chatgpt-codex-connector"}), resolve_issue_from_pr=lambda _r, _n: "octo/widget#42", ) - assert decision.should_queue - assert decision.task == "handle_pr_conversation" - assert decision.directive is True - assert decision.directive_body == "Found two issues in the diff: ..." - assert decision.directive_author == "chatgpt-codex-connector" + assert not decision.should_queue + assert decision.reason == "incoming PR comments ignored" def test_route_reviewer_bot_review_comment_is_directive() -> None: @@ -632,6 +754,7 @@ def test_route_reviewer_bot_review_comment_is_directive() -> None: assert decision.directive is True assert decision.directive_body == "This branch leaks memory." assert decision.directive_author == "chatgpt-codex-connector" + assert decision.directive_authorizes_impl is False def test_route_random_bot_still_skipped_when_not_in_reviewer_list() -> None: @@ -651,16 +774,16 @@ def test_route_random_bot_still_skipped_when_not_in_reviewer_list() -> None: assert "bot" in decision.reason -def test_route_reviewer_bot_login_case_insensitive() -> None: +def test_route_reviewer_bot_login_case_insensitive_for_review_comments() -> None: decision = route( - "issue_comment", + "pull_request_review_comment", { "action": "created", "comment": { "user": {"login": "ChatGPT-Codex-Connector", "type": "Bot"}, "body": "feedback", }, - "issue": {"number": 9, "pull_request": {"url": "x"}}, + "pull_request": {"number": 9, "user": {"login": BOT}}, "repository": {"full_name": "octo/widget"}, }, allowlist=ALLOWLIST, @@ -693,16 +816,16 @@ def test_route_directive_strips_pragmas_from_maintainer_comment() -> None: assert decision.directive_pragmas == (("model", "gpt"), ("thinking", "low")) -def test_route_directive_strips_pragmas_from_reviewer_bot_comment() -> None: +def test_route_directive_strips_pragmas_from_reviewer_bot_review_comment() -> None: decision = route( - "issue_comment", + "pull_request_review_comment", { "action": "created", "comment": { "user": {"login": "chatgpt-codex-connector", "type": "Bot"}, "body": "/model claude\nLeak in foo()", }, - "issue": {"number": 9, "pull_request": {"url": "x"}}, + "pull_request": {"number": 9, "user": {"login": BOT}}, "repository": {"full_name": "octo/widget"}, }, allowlist=ALLOWLIST, diff --git a/python/robomp/tests/test_host_tools.py b/python/robomp/tests/test_host_tools.py index 37fa4225b..850173f80 100644 --- a/python/robomp/tests/test_host_tools.py +++ b/python/robomp/tests/test_host_tools.py @@ -350,6 +350,7 @@ def test_gh_post_comment_propagates_github_error(db: Database, tmp_path: Path) - def test_gh_open_pr_requires_template_sections(db: Database, tmp_path: Path) -> None: transport = httpx.MockTransport(lambda r: httpx.Response(500)) bindings, loop, t = _bindings(db, tmp_path, transport) + db.set_issue_classification(bindings.issue_key, "bug") try: tool = next(x for x in build(bindings) if x.name == "gh_open_pr") with pytest.raises(RpcCommandError) as exc: @@ -684,6 +685,348 @@ def _pr_bindings( return bindings, loop, thread +def _review_bindings( + db: Database, tmp_path: Path, transport: httpx.MockTransport +) -> tuple[ToolBindings, asyncio.AbstractEventLoop, threading.Thread]: + github = GitHubClient("token", transport=transport) + loop, thread = _make_loop_in_background() + issue = IssueInfo( + repo="octo/widget", + number=99, + title="contributor PR", + body="body", + state="open", + author="alice", + labels=(), + is_pull_request=True, + ) + workspace = _stub_workspace(tmp_path) + workspace.issue_number = 99 + bindings = ToolBindings( + db=db, + github=github, + git_transport=LocalGitTransport(token=None), + repo=_stub_repo(), + issue=issue, + workspace=workspace, + loop=loop, + author_name="robomp-bot", + author_email="robomp-bot@example.invalid", + inbound_thread_number=99, + inbound_is_pr=True, + review_mode=True, + ) + db.upsert_issue( + key=bindings.issue_key, + repo="octo/widget", + number=99, + state="reviewing", + branch=bindings.workspace.branch, + session_dir=str(bindings.workspace.session_dir), + pr_number=99, + ) + return bindings, loop, thread + + +def test_fetch_pr_returns_premise_and_changed_files(db: Database, tmp_path: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/repos/octo/widget/pulls/99": + return httpx.Response( + 200, + json={ + "number": 99, + "html_url": "https://github.com/octo/widget/pull/99", + "title": "Fix crash", + "body": "Fixes #42", + "head": {"ref": "fix-crash", "repo": {"full_name": "alice/widget"}}, + "base": {"ref": "main"}, + "state": "open", + "user": {"login": "alice"}, + }, + ) + if request.url.path == "/repos/octo/widget/pulls/99/files": + return httpx.Response( + 200, + json=[{"filename": "src/app.py", "status": "modified", "additions": 5, "deletions": 2}], + ) + return httpx.Response(404, json={"message": "unrouted"}) + + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler)) + try: + tool = next(x for x in build(bindings) if x.name == "fetch_pr") + result = tool.execute({}, _ctx()) + finally: + _stop_loop(loop, t) + + assert "Fix crash" in result + assert "#42" in result + assert "`src/app.py` (modified, +5/-2)" in result + + +def test_classify_pr_applies_review_labels_and_persists_rank(db: Database, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=[{"name": label} for label in captured["body"]["labels"]]) + + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler)) + try: + tool = next(x for x in build(bindings) if x.name == "classify_pr") + result = tool.execute( + { + "rank": "review:p1", + "type": "fix", + "area": ["tool", "unknown"], + "provider": "provider:openai", + "rationale": "fixes the tool crash with a scoped guard", + }, + _ctx(), + ) + finally: + _stop_loop(loop, t) + + assert "review:p1" in result + assert captured["path"].endswith("/issues/99/labels") + assert captured["body"]["labels"] == ["triaged", "review:p1", "fix", "tool", "providers", "provider:openai"] + row = db.get_issue(bindings.issue_key) + assert row is not None and row.classification == "review:p1" + + +def test_classify_pr_rejects_bad_rank(db: Database, tmp_path: Path) -> None: + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + try: + tool = next(x for x in build(bindings) if x.name == "classify_pr") + with pytest.raises(RpcCommandError): + tool.execute({"rank": "prio:p1", "type": "fix", "rationale": "wrong namespace"}, _ctx()) + finally: + _stop_loop(loop, t) + + +def test_pr_review_comment_stages_and_submit_flushes_one_comment_review(db: Database, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/reviews"): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": 44, + "user": {"login": "robomp-bot"}, + "body": captured["body"]["body"], + "state": "COMMENTED", + "submitted_at": "t", + }, + ) + return httpx.Response(404, json={"message": "unrouted"}) + + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler)) + try: + stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment") + submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review") + staged = stage_tool.execute( + { + "path": "src/app.py", + "line": 12, + "side": "RIGHT", + "start_line": 10, + "start_side": "RIGHT", + "body": "blocking: this dereferences cfg before the guard.", + }, + _ctx(), + ) + assert "staged_count=1" in staged + rows = db.list_staged_review_comments(bindings.issue_key) + assert len(rows) == 1 + assert rows[0].path == "src/app.py" + + result = submit_tool.execute({"body": "review:p1 — one blocking issue", "event": "APPROVE"}, _ctx()) + finally: + _stop_loop(loop, t) + + assert "submitted PR review" in result + assert captured["path"].endswith("/pulls/99/reviews") + assert captured["body"] == { + "body": "review:p1 — one blocking issue", + "event": "COMMENT", + "comments": [ + { + "path": "src/app.py", + "line": 12, + "side": "RIGHT", + "body": "blocking: this dereferences cfg before the guard.", + "start_line": 10, + "start_side": "RIGHT", + } + ], + } + assert db.list_staged_review_comments(bindings.issue_key) == [] + + +def test_submit_pr_review_posts_summary_only_when_no_staged_comments(db: Database, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={"id": 45, "user": {"login": "robomp-bot"}, "body": "ok", "state": "COMMENTED", "submitted_at": "t"}, + ) + + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler)) + try: + tool = next(x for x in build(bindings) if x.name == "submit_pr_review") + result = tool.execute({"body": "lgtm — scoped fix"}, _ctx()) + finally: + _stop_loop(loop, t) + + assert "comments=0" in result + assert captured["body"]["event"] == "COMMENT" + assert captured["body"]["comments"] == [] + + +def test_submit_pr_review_failure_keeps_staged_comments(db: Database, tmp_path: Path) -> None: + bindings, loop, t = _review_bindings( + db, + tmp_path, + httpx.MockTransport(lambda _request: httpx.Response(422, json={"message": "Validation failed"})), + ) + try: + stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment") + submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review") + stage_tool.execute({"path": "src/app.py", "line": 12, "body": "finding"}, _ctx()) + with pytest.raises(RpcCommandError): + submit_tool.execute({"body": "summary"}, _ctx()) + finally: + _stop_loop(loop, t) + + rows = db.list_staged_review_comments(bindings.issue_key) + assert len(rows) == 1 + assert rows[0].path == "src/app.py" + + +def test_review_tools_reject_outside_review_mode(db: Database, tmp_path: Path) -> None: + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + try: + tool = next(x for x in build(bindings) if x.name == "pr_review_comment") + with pytest.raises(RpcCommandError): + tool.execute({"path": "x.py", "line": 1, "body": "nit"}, _ctx()) + finally: + _stop_loop(loop, t) + + +def test_review_mode_rejects_push_and_open_pr_before_repo_commands(db: Database, tmp_path: Path) -> None: + calls: list[list[str] | tuple[str, ...]] = [] + + def record_repo_command(_bindings: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None): + del timeout + calls.append(cmd) + raise AssertionError("repo command must not run in review mode") + + bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + try: + original = host_tools._run_repo_command + host_tools._run_repo_command = record_repo_command # type: ignore[assignment] + push = next(x for x in build(bindings) if x.name == "gh_push_branch") + open_pr = next(x for x in build(bindings) if x.name == "gh_open_pr") + with pytest.raises(RpcCommandError): + push.execute({}, _ctx()) + with pytest.raises(RpcCommandError): + open_pr.execute({"title": "t", "body": "invalid"}, _ctx()) + finally: + host_tools._run_repo_command = original # type: ignore[assignment] + _stop_loop(loop, t) + + assert calls == [] + + +def test_impl_gate_rejects_unauthorized_proposal_before_repo_commands( + db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[list[str] | tuple[str, ...]] = [] + + def record_repo_command(_bindings: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None): + del timeout + calls.append(cmd) + raise AssertionError("repo command must not run before implementation authorization") + + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + db.set_issue_classification(bindings.issue_key, "proposal") + monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command) + try: + push = next(x for x in build(bindings) if x.name == "gh_push_branch") + open_pr = next(x for x in build(bindings) if x.name == "gh_open_pr") + with pytest.raises(RpcCommandError) as push_exc: + push.execute({}, _ctx()) + with pytest.raises(RpcCommandError) as pr_exc: + open_pr.execute({"title": "fix: x", "body": "invalid"}, _ctx()) + finally: + _stop_loop(loop, t) + + for msg in (str(push_exc.value), str(pr_exc.value)): + assert "classified `proposal`" in msg + assert "OWNER or allowlisted maintainer" in msg + assert "gh_post_comment" in msg + assert calls == [] + rows = db._conn.execute( + "SELECT tool, error FROM tool_calls WHERE tool IN ('gh_push_branch', 'gh_open_pr') ORDER BY id" + ).fetchall() + assert [row["tool"] for row in rows] == ["gh_push_branch", "gh_open_pr"] + assert all("classified `proposal`" in row["error"] for row in rows) + + +def test_impl_gate_allows_authorized_proposal_to_reach_pr_validation(db: Database, tmp_path: Path) -> None: + from dataclasses import replace + + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + db.set_issue_classification(bindings.issue_key, "proposal") + bindings = replace(bindings, impl_authorized=True) + try: + tool = next(x for x in build(bindings) if x.name == "gh_open_pr") + with pytest.raises(RpcCommandError) as exc: + tool.execute({"title": "fix: x", "body": ""}, _ctx()) + finally: + _stop_loop(loop, t) + + msg = str(exc.value) + assert "requires a non-empty 'body'" in msg + assert "OWNER or allowlisted maintainer" not in msg + + +def test_impl_gate_allows_bug_without_directive_to_reach_pr_validation(db: Database, tmp_path: Path) -> None: + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + db.set_issue_classification(bindings.issue_key, "bug") + try: + tool = next(x for x in build(bindings) if x.name == "gh_open_pr") + with pytest.raises(RpcCommandError) as exc: + tool.execute({"title": "fix: x", "body": ""}, _ctx()) + finally: + _stop_loop(loop, t) + + msg = str(exc.value) + assert "requires a non-empty 'body'" in msg + assert "OWNER or allowlisted maintainer" not in msg + + +def test_impl_gate_allows_existing_proposal_pr_to_reach_pr_validation(db: Database, tmp_path: Path) -> None: + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500))) + db.set_issue_classification(bindings.issue_key, "proposal") + db.set_issue_pr(bindings.issue_key, 7) + try: + tool = next(x for x in build(bindings) if x.name == "gh_open_pr") + with pytest.raises(RpcCommandError) as exc: + tool.execute({"title": "fix: x", "body": ""}, _ctx()) + finally: + _stop_loop(loop, t) + + msg = str(exc.value) + assert "requires a non-empty 'body'" in msg + assert "OWNER or allowlisted maintainer" not in msg + + def test_classify_issue_on_pr_thread_is_noop(db: Database, tmp_path: Path) -> None: """On PR threads the tool must not hit GitHub and must not raise.""" calls: list[str] = [] @@ -1021,6 +1364,7 @@ def test_gh_push_branch_rejects_wrong_identity(db: Database, tmp_path: Path) -> branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: tool.execute({}, _ctx()) @@ -1139,6 +1483,7 @@ def test_gh_open_pr_rejects_wrong_identity_before_push_or_pr(db: Database, tmp_p branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_open_pr") body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n" with pytest.raises(RpcCommandError) as exc: @@ -1259,6 +1604,7 @@ def test_gh_push_branch_rejects_invalid_identity_scan_range(db: Database, tmp_pa branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: tool.execute({}, _ctx()) @@ -1284,6 +1630,7 @@ def test_gh_push_branch_rejects_invalid_identity_scan_range(db: Database, tmp_pa def test_gh_open_pr_requires_closes_keyword(db: Database, tmp_path: Path) -> None: """gh_open_pr refuses if the body has the four sections but no Fixes/Closes/Resolves keyword.""" bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda r: httpx.Response(500))) + db.set_issue_classification(bindings.issue_key, "bug") try: tool = next(x for x in build(bindings) if x.name == "gh_open_pr") body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n" @@ -1316,6 +1663,7 @@ def test_gh_open_pr_refuses_failed_bun_check_before_push_or_pr( ) bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler)) + db.set_issue_classification(bindings.issue_key, "bug") fakebin = tmp_path / "fakebin" fakebin.mkdir() fake_bun = fakebin / "bun" @@ -1455,6 +1803,7 @@ def test_gh_push_branch_rejects_dirty_worktree(db: Database, tmp_path: Path) -> branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: tool.execute({}, _ctx()) @@ -1602,6 +1951,7 @@ def test_gh_push_branch_runs_fix_and_check_before_pushing( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") result = tool.execute({}, _ctx()) finally: @@ -1735,6 +2085,7 @@ def test_gh_push_branch_force_with_lease_recovers_after_amend(db: Database, tmp_ branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") tool.execute({}, _ctx()) @@ -1913,6 +2264,7 @@ def test_gh_push_branch_aborts_on_failed_bun_check( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: tool.execute({}, _ctx()) @@ -2068,6 +2420,7 @@ def test_gh_push_branch_skip_checks_bypasses_failing_bun_check( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") result = tool.execute({"skip_checks": True}, _ctx()) finally: @@ -2205,6 +2558,7 @@ def test_gh_push_branch_skip_checks_still_refuses_dirty_worktree( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: tool.execute({"skip_checks": True}, _ctx()) @@ -2371,6 +2725,7 @@ def test_gh_open_pr_runs_fix_then_check_and_commits_fixup( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_open_pr") body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n" result = tool.execute({"title": "fix: x", "body": body}, _ctx()) @@ -2544,6 +2899,7 @@ def test_gh_open_pr_refuses_dirty_worktree_before_fix( branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") push_tool = next(x for x in build(bindings) if x.name == "gh_push_branch") with pytest.raises(RpcCommandError) as exc: push_tool.execute({}, _ctx()) @@ -2727,6 +3083,7 @@ def test_gh_open_pr_skips_fix_when_no_script(db: Database, tmp_path: Path, monke branch=ws.branch, session_dir=str(ws.session_dir), ) + db.set_issue_classification(bindings.issue_key, "bug") tool = next(x for x in build(bindings) if x.name == "gh_open_pr") body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n" result = tool.execute({"title": "fix: x", "body": body}, _ctx()) diff --git a/python/robomp/tests/test_persona.py b/python/robomp/tests/test_persona.py index 094f9c835..6d9f1e2a3 100644 --- a/python/robomp/tests/test_persona.py +++ b/python/robomp/tests/test_persona.py @@ -36,6 +36,16 @@ class _Workspace: repo_dir: str = "/tmp/repo" +@dataclass(slots=True, frozen=True) +class _Pr: + number: int = 99 + author: str = "alice" + head_ref: str = "fix-crash" + base_ref: str = "main" + head_repo: str = "alice/widget" + html_url: str = "https://github.com/octo/widget/pull/99" + + @dataclass(slots=True, frozen=True) class _Comment: id: int = 1 @@ -151,3 +161,23 @@ def test_resume_triage_renders_branch_and_issue() -> None: assert "broken thing" in out # The prompt instructs the agent to reconcile drift via fetch_issue_thread. assert "fetch_issue_thread" in out + + +def test_kickoff_pr_review_formats_head_repo_and_origin_base() -> None: + out = persona.kickoff_pr_review( + repo=_Repo(), + pr=_Pr(), + workspace=_Workspace(), + ) + assert "`fix-crash` from `alice/widget`" in out + assert "git diff origin/main...HEAD" in out + + +def test_review_completion_reminder_mentions_submit_only() -> None: + out = persona.review_completion_reminder( + repo=_Repo(), + issue=_Issue(number=99, title="Fix parser"), + workspace=_Workspace(branch="review/pr-99"), + ) + assert "submit_pr_review" in out + assert "gh_open_pr" not in out diff --git a/python/robomp/tests/test_proxy_client.py b/python/robomp/tests/test_proxy_client.py index 62e3e1516..691ba5519 100644 --- a/python/robomp/tests/test_proxy_client.py +++ b/python/robomp/tests/test_proxy_client.py @@ -22,6 +22,7 @@ from robomp.github_client import ( GitHubError, IssueInfo, IssueSummary, + PullRequestFileInfo, PullRequestInfo, PullRequestReviewInfo, ReactionInfo, @@ -276,7 +277,7 @@ def round_trip_app(proxy_settings: Settings): } ], ) - if path == "/repos/octo/widget/pulls/2/reviews": + if path == "/repos/octo/widget/pulls/2/reviews" and req.method == "GET": return httpx.Response( 200, json=[ @@ -289,6 +290,25 @@ def round_trip_app(proxy_settings: Settings): } ], ) + if path == "/repos/octo/widget/pulls/2/files": + return httpx.Response( + 200, + json=[{"filename": "src/app.py", "status": "modified", "additions": 2, "deletions": 1}], + ) + if path == "/repos/octo/widget/pulls/2/reviews" and req.method == "POST": + body = json.loads(req.content) + assert body["event"] == "COMMENT" + assert body["comments"] == [{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}] + return httpx.Response( + 200, + json={ + "id": 55, + "user": {"login": "robomp-bot"}, + "body": body["body"], + "state": "COMMENTED", + "submitted_at": "2026-01-01T00:00:00Z", + }, + ) if path == "/user": return httpx.Response(200, json={"login": "robomp-bot"}) if path == "/repos/octo/widget/pulls/4" and req.method == "GET": @@ -353,6 +373,19 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None: prs = await client.list_pr_reviews("octo/widget", 2) assert len(prs) == 1 and isinstance(prs[0], PullRequestReviewInfo) + files = await client.list_pr_files("octo/widget", 2) + assert len(files) == 1 and isinstance(files[0], PullRequestFileInfo) + assert files[0].path == "src/app.py" + + submitted = await client.submit_pr_review( + repo="octo/widget", + pr_number=2, + body="summary", + event="COMMENT", + comments=[{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}], + ) + assert submitted.id == 55 + assert await client.get_authenticated_login() == "robomp-bot" existing_pr = await client.get_pull_request("octo/widget", 4) diff --git a/python/robomp/tests/test_sandbox.py b/python/robomp/tests/test_sandbox.py index d80f6beea..896a30098 100644 --- a/python/robomp/tests/test_sandbox.py +++ b/python/robomp/tests/test_sandbox.py @@ -393,6 +393,73 @@ def test_ensure_workspace_creates_worktree(tmp_path: Path, upstream_repo: Path) assert ws.artifacts_dir.is_dir() +def test_ensure_workspace_pr_head_uses_detached_pr_ref(tmp_path: Path, upstream_repo: Path) -> None: + contributor = tmp_path / "contributor" + _git(["clone", str(upstream_repo), str(contributor)], cwd=tmp_path) + (contributor / "README.md").write_text("hello from pr\n", encoding="utf-8") + _git(["-C", str(contributor), "add", "README.md"], cwd=tmp_path) + subprocess.run( + ["git", "commit", "-m", "pr change"], + cwd=str(contributor), + check=True, + capture_output=True, + text=True, + env=os.environ + | { + "GIT_AUTHOR_NAME": "c", + "GIT_AUTHOR_EMAIL": "c@t", + "GIT_COMMITTER_NAME": "c", + "GIT_COMMITTER_EMAIL": "c@t", + }, + ) + pr_head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(contributor), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + _git(["-C", str(contributor), "push", "origin", "HEAD:refs/pull/9/head"], cwd=tmp_path) + + mgr = SandboxManager(tmp_path / "workspaces") + ws = mgr.ensure_workspace( + repo="octo/widget", + number=9, + title="incoming PR", + clone_url=str(upstream_repo), + default_branch="main", + pr_head=9, + author_name="robomp-bot", + author_email="robomp-bot@example.invalid", + ) + + head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(ws.repo_dir), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + symbolic = subprocess.run( + ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + cwd=str(ws.repo_dir), + capture_output=True, + text=True, + check=False, + ) + pushurl = subprocess.run( + ["git", "config", "--get", "remote.origin.pushurl"], + cwd=str(ws.repo_dir), + capture_output=True, + text=True, + check=False, + ) + assert head == pr_head + assert symbolic.returncode != 0 + assert ws.branch == "review/pr-9" + assert pushurl.returncode != 0 + + def test_chown_workspace_noops_when_not_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls: list[tuple[list[str], bool]] = [] diff --git a/python/robomp/tests/test_server.py b/python/robomp/tests/test_server.py index 7c1d58678..557a7d170 100644 --- a/python/robomp/tests/test_server.py +++ b/python/robomp/tests/test_server.py @@ -888,20 +888,20 @@ def test_webhook_rate_limits_unknown_submitter_at_default_cap(rate_limited_setti assert states == ["queued", "queued", "skipped"] -def test_webhook_unmapped_pr_comment_queues_with_pr_key_and_counts_budget( +def test_webhook_incoming_pr_comment_without_directive_skips_without_counting_budget( rate_limited_settings: Settings, ) -> None: app = create_app(rate_limited_settings) with TestClient(app) as client: - queued = _post_pr_issue_comment( + skipped = _post_pr_issue_comment( client, delivery="pr-unmapped", user="stranger", pr_number=900, association="NONE", ) - assert queued.status_code == 202 - assert queued.json()["state"] == "queued" + assert skipped.status_code == 202 + assert skipped.json()["state"] == "skipped" states = [] for i in range(3): @@ -921,8 +921,8 @@ def test_webhook_unmapped_pr_comment_queues_with_pr_key_and_counts_budget( assert unmapped is not None assert unmapped.issue_key == "octo/widget#900" - assert unmapped.last_error is None - assert states == ["queued", "skipped", "skipped"] + assert "incoming PR comments ignored" in (unmapped.last_error or "") + assert states == ["queued", "queued", "skipped"] def test_webhook_contributor_gets_higher_cap(rate_limited_settings: Settings) -> None: @@ -1500,7 +1500,7 @@ def test_webhook_directive_on_unknown_issue_is_queued_with_metadata(env) -> None assert row is not None assert row.state == "queued" directive = row.payload.get("_robomp_directive") - assert directive == {"body": "please refactor X", "author": "can1357", "pragmas": []} + assert directive == {"body": "please refactor X", "author": "can1357", "pragmas": [], "authorizes_impl": True} def test_webhook_maintainer_bypasses_rate_limit( @@ -1554,6 +1554,7 @@ class _RecordingSandbox: clone_url: str, default_branch: str, existing_branch=None, + pr_head: int | None = None, author_name: str = "", author_email: str = "", slot_uid: int | None = None, @@ -1565,6 +1566,7 @@ class _RecordingSandbox: "title": title, "default_branch": default_branch, "existing_branch": existing_branch, + "pr_head": pr_head, "slot_uid": slot_uid, } ) @@ -1581,7 +1583,7 @@ class _RecordingSandbox: wid = f"{repo.replace('/', '__')}__{number}" return _W( - branch=existing_branch or f"farm/auto/{wid}", + branch=existing_branch or (f"review/pr-{pr_head}" if pr_head is not None else f"farm/auto/{wid}"), session_dir=self.tmp_root / wid / "session", context_dir=self.tmp_root / wid / "context", repo_dir=self.tmp_root / wid / "repo", @@ -1823,6 +1825,198 @@ async def test_handle_pr_conversation_repairs_missing_pr_mapping_from_branch( close_database() +async def test_handle_pr_conversation_skips_review_workspace_rows( + settings: Settings, tmp_path: Path, stub_run_task, monkeypatch +) -> None: + from robomp import tasks + from robomp.github_client import GitHubClient, PullRequestInfo + + sandbox = _RecordingSandbox(tmp_path) + db = get_database(settings.sqlite_path) + db.upsert_issue( + key="octo/widget#900", + repo="octo/widget", + number=900, + state="reviewing", + branch="review/pr-900", + pr_number=900, + ) + + async def _get_pull_request(self, repo_full: str, number: int): + assert repo_full == "octo/widget" + assert number == 900 + return PullRequestInfo( + repo="octo/widget", + number=900, + html_url="https://github.com/octo/widget/pull/900", + head_ref="contrib/fix", + base_ref="main", + state="open", + author="alice", + head_repo="alice/widget", + ) + + monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request) + + payload = { + "action": "created", + "issue": { + "number": 900, + "user": {"login": "alice"}, + "pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/900"}, + }, + "comment": {"user": {"login": "can1357"}, "body": "@robomp-bot please re-review", "id": 12}, + "repository": {"full_name": "octo/widget"}, + "_robomp_directive": {"body": "please re-review", "author": "can1357"}, + } + await tasks.handle_pr_conversation( + settings=settings, + db=db, + github=GitHubClient("t"), + git_transport=LocalGitTransport(token=None), + sandbox=sandbox, + payload=payload, + delivery_id="test-pr-review-row", + ) + + assert stub_run_task == [] + assert sandbox.ensure_calls == [] + close_database() + + +async def test_review_pr_retries_when_ranked_but_not_submitted( + settings: Settings, tmp_path: Path, stub_run_task, monkeypatch +) -> None: + from robomp import tasks + from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo + + sandbox = _RecordingSandbox(tmp_path) + db = get_database(settings.sqlite_path) + repo = RepoInfo( + full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False + ) + issue = IssueInfo( + repo="octo/widget", + number=900, + title="Fix parser", + body="body", + state="open", + author="alice", + labels=("triaged", "review:p1"), + is_pull_request=True, + ) + pr = PullRequestInfo( + repo="octo/widget", + number=900, + html_url="https://github.com/octo/widget/pull/900", + head_ref="alice/fix-parser", + base_ref="main", + state="open", + author="alice", + head_repo="alice/widget", + ) + + async def _get_repo(self, repo_full: str): + assert repo_full == "octo/widget" + return repo + + async def _get_issue(self, repo_full: str, number: int): + assert repo_full == "octo/widget" + assert number == 900 + return issue + + async def _get_pull_request(self, repo_full: str, number: int): + assert repo_full == "octo/widget" + assert number == 900 + return pr + + monkeypatch.setattr(GitHubClient, "get_repo", _get_repo) + monkeypatch.setattr(GitHubClient, "get_issue", _get_issue) + monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request) + + await tasks.review_pr( + settings=settings, + db=db, + github=GitHubClient("t"), + sandbox=sandbox, + git_transport=LocalGitTransport(token=None), + payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}}, + delivery_id="d-review-retry", + ) + + assert len(stub_run_task) == 1 + assert stub_run_task[0]["task_kind"] == "review_pr" + assert sandbox.ensure_calls[0]["pr_head"] == 900 + close_database() + + +async def test_review_pr_skips_after_submitted_review( + settings: Settings, tmp_path: Path, stub_run_task, monkeypatch +) -> None: + from robomp import tasks + from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo + + sandbox = _RecordingSandbox(tmp_path) + db = get_database(settings.sqlite_path) + key = issue_key("octo/widget", 900) + db.log_tool_call(issue_key=key, tool="submit_pr_review", args={"body": "done"}, result={"review_id": 12}) + repo = RepoInfo( + full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False + ) + issue = IssueInfo( + repo="octo/widget", + number=900, + title="Fix parser", + body="body", + state="open", + author="alice", + labels=("triaged", "review:p1"), + is_pull_request=True, + ) + pr = PullRequestInfo( + repo="octo/widget", + number=900, + html_url="https://github.com/octo/widget/pull/900", + head_ref="alice/fix-parser", + base_ref="main", + state="open", + author="alice", + head_repo="alice/widget", + ) + + async def _get_repo(self, repo_full: str): + assert repo_full == "octo/widget" + return repo + + async def _get_issue(self, repo_full: str, number: int): + assert repo_full == "octo/widget" + assert number == 900 + return issue + + async def _get_pull_request(self, repo_full: str, number: int): + assert repo_full == "octo/widget" + assert number == 900 + return pr + + monkeypatch.setattr(GitHubClient, "get_repo", _get_repo) + monkeypatch.setattr(GitHubClient, "get_issue", _get_issue) + monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request) + + await tasks.review_pr( + settings=settings, + db=db, + github=GitHubClient("t"), + sandbox=sandbox, + git_transport=LocalGitTransport(token=None), + payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}}, + delivery_id="d-review-skip", + ) + + assert stub_run_task == [] + assert sandbox.ensure_calls == [] + close_database() + + async def test_handle_comment_directive_bootstraps_untriaged_issue( settings: Settings, tmp_path: Path, stub_run_task, monkeypatch ) -> None: diff --git a/python/robomp/tests/test_tasks_directive.py b/python/robomp/tests/test_tasks_directive.py index f32ac8352..331804207 100644 --- a/python/robomp/tests/test_tasks_directive.py +++ b/python/robomp/tests/test_tasks_directive.py @@ -19,12 +19,14 @@ def test_directive_from_payload_parses_pragmas() -> None: assert directive.body == "do the thing" assert directive.author == "can1357" assert directive.pragmas == (("model", "gpt"), ("thinking", "low")) + assert directive.authorizes_impl is False def test_directive_from_payload_missing_pragmas_is_empty_tuple() -> None: directive = _directive_from_payload({"_robomp_directive": {"body": "x", "author": "can1357"}}) assert directive is not None assert directive.pragmas == () + assert directive.authorizes_impl is False def test_directive_from_payload_drops_malformed_pragma_entries() -> None: @@ -46,6 +48,20 @@ def test_directive_from_payload_drops_malformed_pragma_entries() -> None: assert directive.pragmas == (("model", "gpt"),) +def test_directive_from_payload_parses_implementation_authorization() -> None: + directive = _directive_from_payload( + { + "_robomp_directive": { + "body": "do the thing", + "author": "can1357", + "authorizes_impl": True, + } + } + ) + assert directive is not None + assert directive.authorizes_impl is True + + def test_directive_from_payload_returns_none_for_missing_directive() -> None: assert _directive_from_payload({}) is None assert _directive_from_payload({"_robomp_directive": "not-a-mapping"}) is None diff --git a/python/robomp/tests/test_worker.py b/python/robomp/tests/test_worker.py index cbdb1ccc6..8a1edd593 100644 --- a/python/robomp/tests/test_worker.py +++ b/python/robomp/tests/test_worker.py @@ -157,6 +157,40 @@ def _patch_worker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: ) +@pytest.mark.asyncio +async def test_run_task_sets_impl_authorized_from_directive( + tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch +) -> None: + inputs, _bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) + captured: dict[str, bool] = {} + + monkeypatch.setattr(worker, "_build_prompt", lambda *args, **kwargs: "prompt") + + def fake_run_rpc_blocking( + _inputs: worker.TaskInputs, + *, + task_kind: str, + prompt: str, + loop: asyncio.AbstractEventLoop, + bindings: worker.ToolBindings, + directive: worker.DirectiveInfo | None = None, + ) -> str: + del task_kind, prompt, loop, directive + captured["impl_authorized"] = bindings.impl_authorized + return "ok" + + monkeypatch.setattr(worker, "_run_rpc_blocking", fake_run_rpc_blocking) + + result = await worker.run_task( + task_kind="triage_issue", + inputs=inputs, + directive=worker.DirectiveInfo(body="go ahead", author="can1357", authorizes_impl=True), + ) + + assert result == "ok" + assert captured == {"impl_authorized": True} + + @pytest.mark.asyncio async def test_run_rpc_passes_continue_when_session_jsonl_present(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) @@ -659,10 +693,74 @@ async def test_run_rpc_skips_reminder_when_unclassified(tmp_path: Path, settings assert len(fake.prompts) == 1 +@pytest.mark.asyncio +async def test_run_rpc_review_pr_reminds_until_submit_pr_review(tmp_path: Path, settings: Settings) -> None: + inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) + loop = asyncio.new_event_loop() + try: + worker._run_rpc_blocking( + inputs, + task_kind="review_pr", + prompt="kickoff", + loop=loop, + bindings=bindings, # type: ignore[arg-type] + ) + finally: + loop.close() + fake = _FakeRpcClient.instances[0] + assert len(fake.prompts) == 1 + settings.task_completion_max_reminders + assert fake.prompts[0] == "kickoff" + assert all("submit_pr_review" in p for p in fake.prompts[1:]) + assert all("gh_open_pr" not in p for p in fake.prompts[1:]) + + +@pytest.mark.asyncio +async def test_run_rpc_review_pr_stops_after_submit_without_dirty_probe( + tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch +) -> None: + inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) + + def _probe(_workspace, _slot_uid): # type: ignore[no-untyped-def] + raise AssertionError("review_pr must not run dirty-state probes") + + monkeypatch.setattr(worker, "_probe_workspace_dirty", _probe) + original_on_tool_end = _FakeRpcClient.on_tool_execution_end + + def _record_tool_end(self, cb) -> None: + self._tool_end_callbacks = getattr(self, "_tool_end_callbacks", []) + self._tool_end_callbacks.append(cb) + + def _on_prompt(client: _FakeRpcClient, _prompt: str) -> None: + for cb in client._tool_end_callbacks: + cb(SimpleNamespace(tool_name="submit_pr_review", result={})) + + _FakeRpcClient.on_tool_execution_end = _record_tool_end # type: ignore[assignment] + try: + _FakeRpcClient.on_prompt = staticmethod(_on_prompt) # type: ignore[attr-defined] + loop = asyncio.new_event_loop() + try: + worker._run_rpc_blocking( + inputs, + task_kind="review_pr", + prompt="kickoff", + loop=loop, + bindings=bindings, # type: ignore[arg-type] + ) + finally: + loop.close() + finally: + _FakeRpcClient.on_tool_execution_end = original_on_tool_end # type: ignore[assignment] + delattr(_FakeRpcClient, "on_prompt") + + fake = _FakeRpcClient.instances[0] + assert fake.prompts == ["kickoff"] + + # --------------------------------------------------------------------------- # Dirty-state watchdog # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_run_rpc_sends_dirty_state_reminder_when_worktree_has_unpushed_work( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch diff --git a/scripts/claude-trace.ts b/scripts/claude-trace.ts new file mode 100755 index 000000000..5fee55399 --- /dev/null +++ b/scripts/claude-trace.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +import { runClaudeTraceCommand, type ClaudeTraceCommandArgs } from "../packages/coding-agent/src/cli/claude-trace-cli"; + +const HELP = `Usage: bun scripts/claude-trace.ts [options] + +Runs Claude Code in a headless PTY behind a local HTTPS proxy, sends "hi", and +prints the first /v1/messages request/response headers and bodies. + +Options: + --command Command to run in the virtual TUI (default: claude) + --message Message to send (default: hi) + --cwd Working directory for the Claude process + --host Proxy bind host (default: 127.0.0.1) + --port Proxy bind port (default: 8080; use 0 for random) + --timeout Overall timeout in milliseconds (default: 120000) + --input-delay Delay before sending input (default: 1000) + --json Print JSON instead of Markdown-ish text + --upstream-insecure Disable TLS verification for the upstream server + -h, --help Show this help +`; + +function readOptionValue(argv: readonly string[], index: number, name: string): { value: string; nextIndex: number } { + const inlinePrefix = `${name}=`; + const current = argv[index] ?? ""; + if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index }; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return { value, nextIndex: index + 1 }; +} + +function parseIntegerOption(value: string, name: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`); + return parsed; +} + +export function parseClaudeTraceScriptArgs(argv: readonly string[]): ClaudeTraceCommandArgs | "help" { + const args: ClaudeTraceCommandArgs = {}; + for (let i = 0; i < argv.length; i++) { + const item = argv[i] ?? ""; + if (item === "-h" || item === "--help") return "help"; + if (item === "--json") { + args.json = true; + continue; + } + if (item === "--upstream-insecure") { + args.upstreamTlsRejectUnauthorized = false; + continue; + } + if (item === "--command" || item.startsWith("--command=")) { + const parsed = readOptionValue(argv, i, "--command"); + args.command = parsed.value; + i = parsed.nextIndex; + continue; + } + if (item === "--message" || item.startsWith("--message=")) { + const parsed = readOptionValue(argv, i, "--message"); + args.message = parsed.value; + i = parsed.nextIndex; + continue; + } + if (item === "--cwd" || item.startsWith("--cwd=")) { + const parsed = readOptionValue(argv, i, "--cwd"); + args.cwd = parsed.value; + i = parsed.nextIndex; + continue; + } + if (item === "--host" || item.startsWith("--host=")) { + const parsed = readOptionValue(argv, i, "--host"); + args.host = parsed.value; + i = parsed.nextIndex; + continue; + } + if (item === "--port" || item.startsWith("--port=")) { + const parsed = readOptionValue(argv, i, "--port"); + args.port = parseIntegerOption(parsed.value, "--port"); + i = parsed.nextIndex; + continue; + } + if (item === "--timeout" || item.startsWith("--timeout=")) { + const parsed = readOptionValue(argv, i, "--timeout"); + args.timeoutMs = parseIntegerOption(parsed.value, "--timeout"); + i = parsed.nextIndex; + continue; + } + if (item === "--input-delay" || item.startsWith("--input-delay=")) { + const parsed = readOptionValue(argv, i, "--input-delay"); + args.inputDelayMs = parseIntegerOption(parsed.value, "--input-delay"); + i = parsed.nextIndex; + continue; + } + throw new Error(`Unknown option: ${item}`); + } + return args; +} + +export async function runClaudeTraceScript(argv: readonly string[] = Bun.argv.slice(2)): Promise { + const parsed = parseClaudeTraceScriptArgs(argv); + if (parsed === "help") { + process.stdout.write(HELP); + return; + } + await runClaudeTraceCommand(parsed); +} + +if (import.meta.main) { + try { + await runClaudeTraceScript(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/fix-changelogs.test.ts b/scripts/fix-changelogs.test.ts new file mode 100644 index 000000000..7e4592ccf --- /dev/null +++ b/scripts/fix-changelogs.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import { collectPromotableAddedItemLines, fixChangelogContent } from "./fix-changelogs"; + +describe("collectPromotableAddedItemLines", () => { + it("keeps new changelog item additions while ignoring moves and edits", () => { + const diff = [ + "diff --git a/packages/example/CHANGELOG.md b/packages/example/CHANGELOG.md", + "--- a/packages/example/CHANGELOG.md", + "+++ b/packages/example/CHANGELOG.md", + "@@ -10,0 +11,2 @@", + "+", + "+- Added after the latest tag in a released section.", + "@@ -20 +22 @@", + "-- Moved historical entry.", + "+- Moved historical entry.", + "@@ -30 +32,2 @@", + "-- Historical entry with old wording.", + "+- Historical entry with new wording.", + "+- Another brand-new item in the same hunk.", + ].join("\n"); + + const lines = collectPromotableAddedItemLines(diff); + + expect(lines.get("packages/example/CHANGELOG.md")).toEqual(new Set([12, 33])); + }); +}); + +describe("fixChangelogContent", () => { + it("moves added released-section items to Unreleased and merges duplicate category headings", () => { + const content = [ + "# Changelog", + "", + "## [Unreleased]", + "### Fixed", + "", + "- Existing fix.", + "", + "### Fixed", + "", + "- Second fix.", + "", + "## [1.0.0] - 2026-01-01", + "", + "### Added", + "", + "- Historical addition.", + "- New addition in released section.", + "", + "### Fixed", + "", + "- Historical fix.", + "- New fix in released section.", + "", + ].join("\n"); + + const result = fixChangelogContent(content, new Set([17, 22])); + + expect(result.promotedItems).toBe(2); + expect(result.mergedDuplicateHeadings).toBe(1); + expect(result.content).toBe([ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Added", + "", + "- New addition in released section.", + "", + "### Fixed", + "", + "- Existing fix.", + "- Second fix.", + "- New fix in released section.", + "", + "## [1.0.0] - 2026-01-01", + "", + "### Added", + "", + "- Historical addition.", + "", + "### Fixed", + "", + "- Historical fix.", + "", + ].join("\n")); + }); +}); diff --git a/scripts/fix-changelogs.ts b/scripts/fix-changelogs.ts new file mode 100755 index 000000000..a3e203b88 --- /dev/null +++ b/scripts/fix-changelogs.ts @@ -0,0 +1,701 @@ +#!/usr/bin/env bun + +import { $, Glob } from "bun"; +import * as path from "node:path"; + +const CHANGELOG_GLOB = "packages/*/CHANGELOG.md"; +const ORDERED_SECTION_TITLES = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; + +interface NumberedLine { + text: string; + lineNumber: number; +} + +interface Subsection { + title: string; + lines: NumberedLine[]; +} + +interface ReleaseSection { + heading: string; + title: string; + leadingLines: NumberedLine[]; + subsections: Subsection[]; +} + +interface ChangelogDocument { + prefixLines: NumberedLine[]; + sections: ReleaseSection[]; +} + +interface ParsedItem { + startLine: number; + endLine: number; + lines: string[]; +} + +interface FixCounters { + promotedItems: number; + mergedDuplicateHeadings: number; + removedEmptyHeadings: number; +} + +export interface FixChangelogContentResult extends FixCounters { + content: string; +} + +interface HunkRef { + path: string; + index: number; +} + +interface AddedItemCandidate { + path: string; + lineNumber: number; + text: string; + hunk: HunkRef; + pairedWithRemoval: boolean; +} + +interface RemovedItemOccurrence { + path: string; + text: string; + hunk: HunkRef; + pairedWithAddition: boolean; +} + +export interface ChangedChangelogSummary extends FixCounters { + path: string; +} + +export interface RunChangelogFixerOptions { + repoRoot?: string; + since?: string; + write?: boolean; +} + +export interface RunChangelogFixerResult { + since: string; + changedFiles: ChangedChangelogSummary[]; +} + +interface CliOptions { + mode: "write" | "dry-run" | "check"; + repoRoot?: string; + since?: string; + help: boolean; +} + +function isReleaseHeading(line: string): boolean { + return /^## \[[^\]]+\]/.test(line); +} + +function isSubsectionHeading(line: string): boolean { + return /^###\s+\S/.test(line); +} + +function parseReleaseTitle(heading: string): string { + const match = heading.match(/^## \[([^\]]+)\]/); + return match?.[1] ?? heading.replace(/^##\s+/, "").trim(); +} + +function parseSubsectionTitle(heading: string): string { + return heading.replace(/^###\s+/, "").trim(); +} + +function isListItemLine(line: string): boolean { + return line.trimStart().startsWith("- "); +} + +function normalizeItemText(text: string): string { + return text.trim(); +} + +function splitContentLines(content: string): string[] { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (normalized.endsWith("\n")) { + return normalized.slice(0, -1).split("\n"); + } + return normalized.split("\n"); +} + +function createNumberedLine(text: string, lineNumber: number): NumberedLine { + return { text, lineNumber }; +} + +function parseChangelog(content: string): ChangelogDocument { + const lines = splitContentLines(content); + const numberedLines = lines.map((text, index) => createNumberedLine(text, index + 1)); + const prefixLines: NumberedLine[] = []; + const sections: ReleaseSection[] = []; + let index = 0; + + while (index < numberedLines.length && !isReleaseHeading(numberedLines[index]?.text ?? "")) { + const line = numberedLines[index]; + if (line) prefixLines.push(line); + index++; + } + + while (index < numberedLines.length) { + const headingLine = numberedLines[index]; + if (!headingLine) break; + index++; + + const bodyLines: NumberedLine[] = []; + while (index < numberedLines.length && !isReleaseHeading(numberedLines[index]?.text ?? "")) { + const line = numberedLines[index]; + if (line) bodyLines.push(line); + index++; + } + + sections.push(parseReleaseSection(headingLine.text, bodyLines)); + } + + return { prefixLines, sections }; +} + +function parseReleaseSection(heading: string, bodyLines: readonly NumberedLine[]): ReleaseSection { + const leadingLines: NumberedLine[] = []; + const subsections: Subsection[] = []; + let index = 0; + + while (index < bodyLines.length && !isSubsectionHeading(bodyLines[index]?.text ?? "")) { + const line = bodyLines[index]; + if (line) leadingLines.push(line); + index++; + } + + while (index < bodyLines.length) { + const headingLine = bodyLines[index]; + if (!headingLine) break; + index++; + + const lines: NumberedLine[] = []; + while (index < bodyLines.length && !isSubsectionHeading(bodyLines[index]?.text ?? "")) { + const line = bodyLines[index]; + if (line) lines.push(line); + index++; + } + + subsections.push({ title: parseSubsectionTitle(headingLine.text), lines }); + } + + return { + heading, + title: parseReleaseTitle(heading), + leadingLines, + subsections, + }; +} + +function trimBlankLines(lines: readonly string[]): string[] { + let start = 0; + let end = lines.length; + while (start < end && lines[start]?.trim() === "") start++; + while (end > start && lines[end - 1]?.trim() === "") end--; + return lines.slice(start, end); +} + +function numberedText(lines: readonly NumberedLine[]): string[] { + return lines.map(line => line.text); +} + +function syntheticLines(lines: readonly string[]): NumberedLine[] { + return lines.map(text => ({ text, lineNumber: 0 })); +} + +function appendSubsectionLines(target: Subsection, sourceLines: readonly string[]): void { + const trimmedSource = trimBlankLines(sourceLines); + if (trimmedSource.length === 0) return; + + const existing = trimBlankLines(numberedText(target.lines)); + if (existing.length === 0) { + target.lines = syntheticLines(trimmedSource); + return; + } + + const lastExisting = existing[existing.length - 1] ?? ""; + const firstSource = trimmedSource[0] ?? ""; + const separator = isListItemLine(lastExisting) && isListItemLine(firstSource) ? [] : [""]; + target.lines = syntheticLines([...existing, ...separator, ...trimmedSource]); +} + +function parseItems(lines: readonly NumberedLine[]): ParsedItem[] { + const items: ParsedItem[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + if (!line || !isListItemLine(line.text)) { + index++; + continue; + } + + const start = index; + index++; + while (index < lines.length && !isListItemLine(lines[index]?.text ?? "")) { + index++; + } + + const itemLines = lines.slice(start, index); + const firstLine = itemLines[0]; + const lastLine = itemLines[itemLines.length - 1]; + if (firstLine && lastLine) { + items.push({ + startLine: firstLine.lineNumber, + endLine: lastLine.lineNumber, + lines: trimBlankLines(numberedText(itemLines)), + }); + } + } + + return items; +} + +function lineRangeSet(items: readonly ParsedItem[]): Set { + const lines = new Set(); + for (const item of items) { + for (let line = item.startLine; line <= item.endLine; line++) { + lines.add(line); + } + } + return lines; +} + +function subsectionHasItem(subsection: Subsection, itemLines: readonly string[]): boolean { + const wanted = trimBlankLines(itemLines).join("\n"); + if (!wanted) return true; + for (const item of parseItems(subsection.lines)) { + if (item.lines.join("\n") === wanted) return true; + } + return false; +} + +function getOrCreateUnreleasedSection(document: ChangelogDocument): ReleaseSection { + const existing = document.sections.find(section => section.title === "Unreleased"); + if (existing) return existing; + + const section: ReleaseSection = { + heading: "## [Unreleased]", + title: "Unreleased", + leadingLines: [], + subsections: [], + }; + document.sections.unshift(section); + return section; +} + +function getOrCreateSubsection(section: ReleaseSection, title: string): Subsection { + const existing = section.subsections.findLast(subsection => subsection.title === title); + if (existing) return existing; + + const subsection: Subsection = { title, lines: [] }; + section.subsections.push(subsection); + return subsection; +} + +function titleOrder(title: string): number { + const index = ORDERED_SECTION_TITLES.indexOf(title as (typeof ORDERED_SECTION_TITLES)[number]); + return index === -1 ? ORDERED_SECTION_TITLES.length : index; +} + +function normalizeSection(section: ReleaseSection): FixCounters { + const counters: FixCounters = { + promotedItems: 0, + mergedDuplicateHeadings: 0, + removedEmptyHeadings: 0, + }; + const subsectionByTitle = new Map(); + const normalizedSubsections: Subsection[] = []; + + for (const subsection of section.subsections) { + const trimmedLines = trimBlankLines(numberedText(subsection.lines)); + if (trimmedLines.length === 0) { + counters.removedEmptyHeadings++; + continue; + } + + const existing = subsectionByTitle.get(subsection.title); + if (existing) { + appendSubsectionLines(existing, trimmedLines); + counters.mergedDuplicateHeadings++; + continue; + } + + const normalized: Subsection = { + title: subsection.title, + lines: syntheticLines(trimmedLines), + }; + subsectionByTitle.set(subsection.title, normalized); + normalizedSubsections.push(normalized); + } + + if (section.title === "Unreleased") { + normalizedSubsections.sort((a, b) => titleOrder(a.title) - titleOrder(b.title)); + } + + section.leadingLines = syntheticLines(trimBlankLines(numberedText(section.leadingLines))); + section.subsections = normalizedSubsections; + return counters; +} + +function renderChangelog(document: ChangelogDocument): string { + const output: string[] = []; + const prefix = trimBlankLines(numberedText(document.prefixLines)); + if (prefix.length > 0) { + output.push(...prefix, ""); + } + + for (const section of document.sections) { + output.push(section.heading); + const leading = trimBlankLines(numberedText(section.leadingLines)); + if (leading.length > 0) { + output.push("", ...leading); + } + + for (const subsection of section.subsections) { + const lines = trimBlankLines(numberedText(subsection.lines)); + if (lines.length === 0) continue; + output.push("", `### ${subsection.title}`, "", ...lines); + } + + output.push(""); + } + + while (output.length > 0 && output[output.length - 1] === "") { + output.pop(); + } + return `${output.join("\n")}\n`; +} + +export function fixChangelogContent( + content: string, + promotableAddedItemStartLines: ReadonlySet, +): FixChangelogContentResult { + const document = parseChangelog(content); + let unreleased = document.sections.find(section => section.title === "Unreleased"); + let promotedItems = 0; + + for (const section of document.sections) { + if (section.title === "Unreleased") continue; + + for (const subsection of section.subsections) { + const items = parseItems(subsection.lines).filter(item => promotableAddedItemStartLines.has(item.startLine)); + if (items.length === 0) continue; + + const linesToRemove = lineRangeSet(items); + subsection.lines = subsection.lines.filter(line => !linesToRemove.has(line.lineNumber)); + + unreleased ??= getOrCreateUnreleasedSection(document); + const targetSubsection = getOrCreateSubsection(unreleased, subsection.title); + for (const item of items) { + if (!subsectionHasItem(targetSubsection, item.lines)) { + appendSubsectionLines(targetSubsection, item.lines); + } + promotedItems++; + } + } + } + + let mergedDuplicateHeadings = 0; + let removedEmptyHeadings = 0; + for (const section of document.sections) { + const counters = normalizeSection(section); + mergedDuplicateHeadings += counters.mergedDuplicateHeadings; + removedEmptyHeadings += counters.removedEmptyHeadings; + } + + if (promotedItems === 0 && mergedDuplicateHeadings === 0 && removedEmptyHeadings === 0) { + return { + content, + promotedItems, + mergedDuplicateHeadings, + removedEmptyHeadings, + }; + } + + return { + content: renderChangelog(document), + promotedItems, + mergedDuplicateHeadings, + removedEmptyHeadings, + }; +} + +function hunkKey(hunk: HunkRef): string { + return `${hunk.path}\0${hunk.index}`; +} + +function itemKey(pathName: string, text: string): string { + return `${pathName}\0${normalizeItemText(text)}`; +} + +export function collectPromotableAddedItemLines(diffText: string): Map> { + const candidates: AddedItemCandidate[] = []; + const removals: RemovedItemOccurrence[] = []; + let currentPath = ""; + let oldLine = 0; + let newLine = 0; + let hunkIndex = -1; + + for (const rawLine of diffText.replace(/\r\n/g, "\n").split("\n")) { + if (rawLine.startsWith("+++ b/")) { + currentPath = rawLine.slice("+++ b/".length); + continue; + } + + if (rawLine.startsWith("diff --git ")) { + currentPath = ""; + hunkIndex = -1; + continue; + } + + const hunkMatch = rawLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunkMatch) { + oldLine = Number(hunkMatch[1]); + newLine = Number(hunkMatch[2]); + hunkIndex++; + continue; + } + + if (!currentPath || hunkIndex < 0 || rawLine.length === 0) continue; + + const marker = rawLine[0]; + const text = rawLine.slice(1); + const hunk = { path: currentPath, index: hunkIndex }; + if (marker === "+") { + if (isListItemLine(text)) { + candidates.push({ + path: currentPath, + lineNumber: newLine, + text, + hunk, + pairedWithRemoval: false, + }); + } + newLine++; + continue; + } + + if (marker === "-") { + if (isListItemLine(text)) { + removals.push({ + path: currentPath, + text, + hunk, + pairedWithAddition: false, + }); + } + oldLine++; + continue; + } + + if (marker === " ") { + oldLine++; + newLine++; + } + } + + const removalsByItem = new Map(); + for (const removal of removals) { + const key = itemKey(removal.path, removal.text); + const existing = removalsByItem.get(key); + if (existing) { + existing.push(removal); + } else { + removalsByItem.set(key, [removal]); + } + } + + for (const candidate of candidates) { + const sameItemRemovals = removalsByItem.get(itemKey(candidate.path, candidate.text)); + const matchingRemoval = sameItemRemovals?.find(removal => !removal.pairedWithAddition); + if (matchingRemoval) { + matchingRemoval.pairedWithAddition = true; + candidate.pairedWithRemoval = true; + } + } + + const unpairedRemovalCountByHunk = new Map(); + for (const removal of removals) { + if (removal.pairedWithAddition) continue; + const key = hunkKey(removal.hunk); + unpairedRemovalCountByHunk.set(key, (unpairedRemovalCountByHunk.get(key) ?? 0) + 1); + } + + const linesByPath = new Map>(); + for (const candidate of candidates) { + if (candidate.pairedWithRemoval) continue; + const key = hunkKey(candidate.hunk); + const unpairedRemovalCount = unpairedRemovalCountByHunk.get(key) ?? 0; + if (unpairedRemovalCount > 0) { + unpairedRemovalCountByHunk.set(key, unpairedRemovalCount - 1); + continue; + } + + const existing = linesByPath.get(candidate.path); + if (existing) { + existing.add(candidate.lineNumber); + } else { + linesByPath.set(candidate.path, new Set([candidate.lineNumber])); + } + } + + return linesByPath; +} + +async function git(args: readonly string[], cwd: string): Promise { + const result = await $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}` + .cwd(cwd) + .quiet(); + return result.text(); +} + +async function resolveRepoRoot(repoRoot: string | undefined): Promise { + if (repoRoot) return path.resolve(repoRoot); + return (await git(["rev-parse", "--show-toplevel"], process.cwd())).trim(); +} + +async function latestTag(repoRoot: string): Promise { + return (await git(["describe", "--tags", "--abbrev=0"], repoRoot)).trim(); +} + +async function changelogPaths(repoRoot: string): Promise { + const glob = new Glob(CHANGELOG_GLOB); + const paths: string[] = []; + for await (const changelogPath of glob.scan(repoRoot)) { + paths.push(path.isAbsolute(changelogPath) ? path.relative(repoRoot, changelogPath) : changelogPath); + } + paths.sort(); + return paths; +} + +async function changelogDiff(repoRoot: string, since: string, paths: readonly string[]): Promise { + if (paths.length === 0) return ""; + return git(["diff", "--unified=0", "--no-color", "--no-ext-diff", since, "--", ...paths], repoRoot); +} + +export async function runChangelogFixer(options: RunChangelogFixerOptions = {}): Promise { + const repoRoot = await resolveRepoRoot(options.repoRoot); + const since = options.since ?? (await latestTag(repoRoot)); + const paths = await changelogPaths(repoRoot); + const diff = await changelogDiff(repoRoot, since, paths); + const addedItemLines = collectPromotableAddedItemLines(diff); + const changedFiles: ChangedChangelogSummary[] = []; + + for (const changelogPath of paths) { + const absolutePath = path.join(repoRoot, changelogPath); + const currentContent = await Bun.file(absolutePath).text(); + const result = fixChangelogContent(currentContent, addedItemLines.get(changelogPath) ?? new Set()); + if (result.content === currentContent) continue; + + changedFiles.push({ + path: changelogPath, + promotedItems: result.promotedItems, + mergedDuplicateHeadings: result.mergedDuplicateHeadings, + removedEmptyHeadings: result.removedEmptyHeadings, + }); + + if (options.write !== false) { + await Bun.write(absolutePath, result.content); + } + } + + return { since, changedFiles }; +} + +function parseCliArgs(args: readonly string[]): CliOptions { + const options: CliOptions = { mode: "write", help: false }; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + switch (arg) { + case "--dry-run": + options.mode = "dry-run"; + break; + case "--check": + options.mode = "check"; + break; + case "--since": { + const value = args[index + 1]; + if (!value) throw new Error("--since requires a tag or commit"); + options.since = value; + index++; + break; + } + case "--repo-root": { + const value = args[index + 1]; + if (!value) throw new Error("--repo-root requires a path"); + options.repoRoot = value; + index++; + break; + } + case "-h": + case "--help": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function usage(): string { + return [ + "Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since ]", + "", + "Moves changelog items added since the latest tag from released sections into [Unreleased],", + "then removes duplicate or empty ### category headings.", + "", + "Options:", + " --dry-run Print what would change without writing files.", + " --check Exit 1 if any changelog would change.", + " --since Compare changelog additions against this tag/commit instead of latest tag.", + " --repo-root Run against an explicit repository root.", + ].join("\n"); +} + +function printSummary(result: RunChangelogFixerResult, mode: CliOptions["mode"]): void { + const suffix = mode === "write" ? "" : ` (${mode}, not written)`; + if (result.changedFiles.length === 0) { + console.log(`Changelogs already clean since ${result.since}.`); + return; + } + + console.log(`Fixed ${result.changedFiles.length} changelog(s) since ${result.since}${suffix}:`); + for (const file of result.changedFiles) { + const parts = [ + `${file.promotedItems} promoted item(s)`, + `${file.mergedDuplicateHeadings} merged duplicate heading(s)`, + `${file.removedEmptyHeadings} removed empty heading(s)`, + ]; + console.log(` ${file.path}: ${parts.join(", ")}`); + } +} + +async function main(): Promise { + try { + const cliOptions = parseCliArgs(process.argv.slice(2)); + if (cliOptions.help) { + console.log(usage()); + return; + } + + const result = await runChangelogFixer({ + repoRoot: cliOptions.repoRoot, + since: cliOptions.since, + write: cliOptions.mode === "write", + }); + printSummary(result, cliOptions.mode); + if (cliOptions.mode === "check" && result.changedFiles.length > 0) { + process.exit(1); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/release.ts b/scripts/release.ts index bae15a4b3..95fb46b6a 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -10,6 +10,7 @@ */ import { $, Glob } from "bun"; +import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); @@ -305,6 +306,14 @@ async function cmdRelease(version: string): Promise { // 5. Update changelogs console.log("Updating CHANGELOGs..."); + const fixResult = await runChangelogFixer({ since: latestTag }); + for (const fixed of fixResult.changedFiles) { + console.log( + ` Fixed ${fixed.path}: ${fixed.promotedItems} promoted, ` + + `${fixed.mergedDuplicateHeadings} duplicate heading(s) merged, ` + + `${fixed.removedEmptyHeadings} empty heading(s) removed`, + ); + } await updateChangelogsForRelease(version); console.log();