Compare commits
75 Commits
feature/ag
...
fix/hashli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55c8952668 | ||
|
|
5d30ec80df | ||
|
|
f50f3d3c37 | ||
|
|
833c26ae5c | ||
|
|
60cf2de16f | ||
|
|
c7efe8f002 | ||
|
|
54b756c145 | ||
|
|
1cb362773b | ||
|
|
08b663df86 | ||
|
|
fddd6f1306 | ||
|
|
e11c217d15 | ||
|
|
6ec0ff732b | ||
|
|
ebd26b7421 | ||
|
|
9f804c2a6a | ||
|
|
05c04838f4 | ||
|
|
86671ad25c | ||
|
|
ab768029fa | ||
|
|
afec1f2928 | ||
|
|
41fe6ad2e4 | ||
|
|
b47b034209 | ||
|
|
a37a6044dc | ||
|
|
7a01035736 | ||
|
|
f1076d978e | ||
|
|
3a5aaf6488 | ||
|
|
830dcf8d2f | ||
|
|
96d51418d6 | ||
|
|
b3a6aaa843 | ||
|
|
1f62fa5b2a | ||
|
|
2428a46e6d | ||
|
|
b709fa8e83 | ||
|
|
0dc5f56af4 | ||
|
|
cd6c9cb5dc | ||
|
|
e5aa08b865 | ||
|
|
db15f96cd8 | ||
|
|
ff0e9ac557 | ||
|
|
07113ebe94 | ||
|
|
2d3d993eb6 | ||
|
|
a82f4ee86a | ||
|
|
0cbc6b5410 | ||
|
|
ac3a9fd272 | ||
|
|
41880f8ffb | ||
|
|
35ab9b19c8 | ||
|
|
6245e46885 | ||
|
|
76da95116e | ||
|
|
9933c6654f | ||
|
|
2e845c8d99 | ||
|
|
bcf7fff9b9 | ||
|
|
2d069ce4cc | ||
|
|
09314dba1a | ||
|
|
32a838ad3c | ||
|
|
edf4d522d1 | ||
|
|
0bae7ec4fc | ||
|
|
7e05bd2b8e | ||
|
|
ffa2a255d9 | ||
|
|
07e8a7c570 | ||
|
|
d0b18787ba | ||
|
|
4d7b98d9f2 | ||
|
|
a3e4f904a6 | ||
|
|
c0636e5b0c | ||
|
|
49e885d81d | ||
|
|
bf33e6f651 | ||
|
|
da13a2f673 | ||
|
|
02aff32b0c | ||
|
|
c806a35e49 | ||
|
|
b175c11b35 | ||
|
|
7b55cbab94 | ||
|
|
6904cba061 | ||
|
|
116f17ed11 | ||
|
|
a31109bb07 | ||
|
|
91530234ec | ||
|
|
6aa1e96f9e | ||
|
|
f265e37cbc | ||
|
|
931c0cd101 | ||
|
|
02017a1b70 | ||
|
|
dafdca217b |
@@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい
|
||||
[oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます:
|
||||
|
||||
```
|
||||
11#VK: function hello() {
|
||||
22#XJ: return "world";
|
||||
33#MB: }
|
||||
11#VK| function hello() {
|
||||
22#XJ| return "world";
|
||||
33#MB| }
|
||||
```
|
||||
|
||||
エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。
|
||||
|
||||
@@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가
|
||||
[oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다:
|
||||
|
||||
```
|
||||
11#VK: function hello() {
|
||||
22#XJ: return "world";
|
||||
33#MB: }
|
||||
11#VK| function hello() {
|
||||
22#XJ| return "world";
|
||||
33#MB| }
|
||||
```
|
||||
|
||||
에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다.
|
||||
|
||||
@@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit
|
||||
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash:
|
||||
|
||||
```
|
||||
11#VK: function hello() {
|
||||
22#XJ: return "world";
|
||||
33#MB: }
|
||||
11#VK| function hello() {
|
||||
22#XJ| return "world";
|
||||
33#MB| }
|
||||
```
|
||||
|
||||
The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors.
|
||||
|
||||
@@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是
|
||||
受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值:
|
||||
|
||||
```
|
||||
11#VK: function hello() {
|
||||
22#XJ: return "world";
|
||||
33#MB: }
|
||||
11#VK| function hello() {
|
||||
22#XJ| return "world";
|
||||
33#MB| }
|
||||
```
|
||||
|
||||
Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"hashline_edit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"model_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -288,6 +291,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -495,6 +510,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -702,6 +729,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -909,6 +948,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1116,6 +1167,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1323,6 +1386,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1530,6 +1605,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1737,6 +1824,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1944,6 +2043,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2151,6 +2262,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2358,6 +2481,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2565,6 +2700,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2772,6 +2919,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2979,6 +3138,18 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.7.4",
|
||||
"oh-my-opencode-darwin-x64": "3.7.4",
|
||||
"oh-my-opencode-linux-arm64": "3.7.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.7.4",
|
||||
"oh-my-opencode-linux-x64": "3.7.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.7.4",
|
||||
"oh-my-opencode-windows-x64": "3.7.4",
|
||||
"oh-my-opencode-darwin-arm64": "3.8.1",
|
||||
"oh-my-opencode-darwin-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.1",
|
||||
"oh-my-opencode-linux-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.1",
|
||||
"oh-my-opencode-windows-x64": "3.8.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -228,19 +228,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.8.1",
|
||||
"oh-my-opencode-darwin-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.1",
|
||||
"oh-my-opencode-linux-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.1",
|
||||
"oh-my-opencode-windows-x64": "3.8.1"
|
||||
"oh-my-opencode-darwin-arm64": "3.8.4",
|
||||
"oh-my-opencode-darwin-x64": "3.8.4",
|
||||
"oh-my-opencode-linux-arm64": "3.8.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.4",
|
||||
"oh-my-opencode-linux-x64": "3.8.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.4",
|
||||
"oh-my-opencode-windows-x64": "3.8.4"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1679,6 +1679,30 @@
|
||||
"created_at": "2026-02-21T22:44:45Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2029
|
||||
},
|
||||
{
|
||||
"name": "imadal1n",
|
||||
"id": 97968636,
|
||||
"comment_id": 3940704780,
|
||||
"created_at": "2026-02-22T10:57:33Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2045
|
||||
},
|
||||
{
|
||||
"name": "DMax1314",
|
||||
"id": 54206290,
|
||||
"comment_id": 3943046087,
|
||||
"created_at": "2026-02-23T07:06:14Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2068
|
||||
},
|
||||
{
|
||||
"name": "Firstbober",
|
||||
"id": 22197465,
|
||||
"comment_id": 3946848526,
|
||||
"created_at": "2026-02-23T19:27:59Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2080
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,12 +6,13 @@
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized)
|
||||
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
@@ -20,6 +21,7 @@ import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
|
||||
import { getDefaultAtlasPrompt } from "./default"
|
||||
import { getGptAtlasPrompt } from "./gpt"
|
||||
import { getGeminiAtlasPrompt } from "./gemini"
|
||||
import {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export type AtlasPromptSource = "default" | "gpt"
|
||||
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
||||
|
||||
/**
|
||||
* Determines which Atlas prompt to use based on model.
|
||||
@@ -39,6 +41,9 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -58,6 +63,8 @@ export function getAtlasPrompt(model?: string): string {
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptAtlasPrompt()
|
||||
case "gemini":
|
||||
return getGeminiAtlasPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultAtlasPrompt()
|
||||
|
||||
@@ -1,14 +1,2 @@
|
||||
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||
export {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
buildCategorySection,
|
||||
buildSkillsSection,
|
||||
buildDecisionMatrix,
|
||||
} from "./prompt-section-builder"
|
||||
|
||||
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
|
||||
export { createAtlasAgent, atlasPromptMetadata } from "./agent"
|
||||
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
|
||||
|
||||
export { isGptModel } from "../types"
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
export * from "./types"
|
||||
export { createBuiltinAgents } from "./builtin-agents"
|
||||
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
export { createSisyphusAgent } from "./sisyphus"
|
||||
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
|
||||
export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
|
||||
export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
|
||||
|
||||
|
||||
export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
|
||||
export { createMetisAgent, METIS_SYSTEM_PROMPT, metisPromptMetadata } from "./metis"
|
||||
export { createMomusAgent, MOMUS_SYSTEM_PROMPT, momusPromptMetadata } from "./momus"
|
||||
export { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
export {
|
||||
PROMETHEUS_SYSTEM_PROMPT,
|
||||
PROMETHEUS_PERMISSION,
|
||||
PROMETHEUS_GPT_SYSTEM_PROMPT,
|
||||
getPrometheusPrompt,
|
||||
getPrometheusPromptSource,
|
||||
getGptPrometheusPrompt,
|
||||
PROMETHEUS_IDENTITY_CONSTRAINTS,
|
||||
PROMETHEUS_INTERVIEW_MODE,
|
||||
PROMETHEUS_PLAN_GENERATION,
|
||||
PROMETHEUS_HIGH_ACCURACY_MODE,
|
||||
PROMETHEUS_PLAN_TEMPLATE,
|
||||
PROMETHEUS_BEHAVIORAL_SUMMARY,
|
||||
} from "./prometheus"
|
||||
export type { PrometheusPromptSource } from "./prometheus"
|
||||
|
||||
@@ -2,15 +2,5 @@ export {
|
||||
PROMETHEUS_SYSTEM_PROMPT,
|
||||
PROMETHEUS_PERMISSION,
|
||||
getPrometheusPrompt,
|
||||
getPrometheusPromptSource,
|
||||
} from "./system-prompt"
|
||||
export type { PrometheusPromptSource } from "./system-prompt"
|
||||
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
|
||||
|
||||
// Re-export individual sections for granular access
|
||||
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
|
||||
export { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
|
||||
export { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
|
||||
export { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
|
||||
export { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
|
||||
export { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
|
||||
|
||||
@@ -5,7 +5,8 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
|
||||
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
|
||||
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
|
||||
import { getGptPrometheusPrompt } from "./gpt"
|
||||
import { isGptModel } from "../types"
|
||||
import { getGeminiPrometheusPrompt } from "./gemini"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
|
||||
/**
|
||||
* Combined Prometheus system prompt (Claude-optimized, default).
|
||||
@@ -30,7 +31,7 @@ export const PROMETHEUS_PERMISSION = {
|
||||
question: "allow" as const,
|
||||
}
|
||||
|
||||
export type PrometheusPromptSource = "default" | "gpt"
|
||||
export type PrometheusPromptSource = "default" | "gpt" | "gemini"
|
||||
|
||||
/**
|
||||
* Determines which Prometheus prompt to use based on model.
|
||||
@@ -39,12 +40,16 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate Prometheus prompt based on model.
|
||||
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
|
||||
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
|
||||
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
|
||||
*/
|
||||
export function getPrometheusPrompt(model?: string): string {
|
||||
@@ -53,6 +58,8 @@ export function getPrometheusPrompt(model?: string): string {
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptPrometheusPrompt()
|
||||
case "gemini":
|
||||
return getGeminiPrometheusPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return PROMETHEUS_SYSTEM_PROMPT
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* - Avoid delegation, preferring to do work themselves
|
||||
* - Claim completion without verification
|
||||
* - Interpret constraints as suggestions
|
||||
* - Skip intent classification gates (jump straight to action)
|
||||
* - Conflate investigation with implementation ("look into X" → starts coding)
|
||||
*
|
||||
* These overlays inject corrective sections at strategic points
|
||||
* in the dynamic Sisyphus prompt to counter these tendencies.
|
||||
@@ -77,3 +79,39 @@ Your internal confidence estimator is miscalibrated toward optimism. What feels
|
||||
4. If you delegated, read EVERY file the subagent touched — not trust their claims
|
||||
</GEMINI_VERIFICATION_OVERRIDE>`;
|
||||
}
|
||||
|
||||
export function buildGeminiIntentGateEnforcement(): string {
|
||||
return `<GEMINI_INTENT_GATE_ENFORCEMENT>
|
||||
## YOU MUST CLASSIFY INTENT BEFORE ACTING. NO EXCEPTIONS.
|
||||
|
||||
**Your failure mode: You skip intent classification and jump straight to implementation.**
|
||||
|
||||
You see a user message and your instinct is to immediately start working. WRONG. You MUST first determine WHAT KIND of work the user wants. Getting this wrong wastes everything that follows.
|
||||
|
||||
**MANDATORY FIRST OUTPUT — before ANY tool call or action:**
|
||||
|
||||
\`\`\`
|
||||
I detect [TYPE] intent — [REASON].
|
||||
My approach: [ROUTING DECISION].
|
||||
\`\`\`
|
||||
|
||||
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
|
||||
|
||||
**SELF-CHECK (answer honestly before proceeding):**
|
||||
|
||||
1. Did the user EXPLICITLY ask me to implement/build/create something? → If NO, do NOT implement.
|
||||
2. Did the user say "look into", "check", "investigate", "explain"? → That means RESEARCH, not implementation.
|
||||
3. Did the user ask "what do you think?" → That means EVALUATION — propose and WAIT, do not execute.
|
||||
4. Did the user report an error? → That means MINIMAL FIX, not refactoring.
|
||||
|
||||
**COMMON MISTAKES YOU MAKE (AND MUST NOT):**
|
||||
|
||||
| User Says | You Want To Do | You MUST Do |
|
||||
| "explain how X works" | Start modifying X | Research X, explain it, STOP |
|
||||
| "look into this bug" | Fix the bug immediately | Investigate, report findings, WAIT for go-ahead |
|
||||
| "what do you think about approach X?" | Implement approach X | Evaluate X, propose alternatives, WAIT |
|
||||
| "improve the tests" | Rewrite all tests | Assess current tests FIRST, propose approach, THEN implement |
|
||||
|
||||
**IF YOU SKIPPED THE INTENT CLASSIFICATION ABOVE:** STOP. Go back. Do it now. Your next tool call is INVALID without it.
|
||||
</GEMINI_INTENT_GATE_ENFORCEMENT>`;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized)
|
||||
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import type { AgentOverrideConfig } from "../../config/schema"
|
||||
import {
|
||||
createAgentToolRestrictions,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
|
||||
import { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
import { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
@@ -32,7 +34,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
temperature: 0.1,
|
||||
} as const
|
||||
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt"
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini"
|
||||
|
||||
/**
|
||||
* Determines which Sisyphus-Junior prompt to use based on model.
|
||||
@@ -41,6 +43,9 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -57,6 +62,8 @@ export function buildSisyphusJuniorPrompt(
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "gemini":
|
||||
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "default":
|
||||
default:
|
||||
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
export { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
|
||||
|
||||
export {
|
||||
SISYPHUS_JUNIOR_DEFAULTS,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk";
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types";
|
||||
import { isGptModel } from "./types";
|
||||
import { isGptModel, isGeminiModel } from "./types";
|
||||
import {
|
||||
buildGeminiToolMandate,
|
||||
buildGeminiDelegationOverride,
|
||||
buildGeminiVerificationOverride,
|
||||
buildGeminiIntentGateEnforcement,
|
||||
} from "./sisyphus-gemini-overlays";
|
||||
|
||||
const MODE: AgentMode = "primary";
|
||||
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
@@ -330,12 +336,11 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
|
||||
\`\`\`
|
||||
|
||||
### Background Result Collection:
|
||||
1. Launch parallel agents → receive task_ids
|
||||
2. Continue immediate work
|
||||
1. Launch parallel agents \u2192 receive task_ids
|
||||
2. Continue immediate work (explore, librarian results)
|
||||
3. When results needed: \`background_output(task_id="...")\`
|
||||
4. Before final answer, cancel DISPOSABLE tasks (explore, librarian) individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
|
||||
5. **NEVER cancel Oracle.** ALWAYS collect Oracle result via \`background_output(task_id="bg_oracle_xxx")\` before answering — even if you already have enough context.
|
||||
6. **NEVER use \`background_cancel(all=true)\`** — it kills Oracle. Cancel each disposable task by its specific taskId.
|
||||
4. **If Oracle is running**: STOP all other output. Follow Oracle Completion Protocol in <Oracle_Usage>.
|
||||
5. Cleanup: Cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Never use \`background_cancel(all=true)\`.
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
@@ -472,9 +477,9 @@ If verification fails:
|
||||
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
|
||||
|
||||
### Before Delivering Final Answer:
|
||||
- Cancel DISPOSABLE background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`
|
||||
- **NEVER use \`background_cancel(all=true)\`.** Always cancel individually by taskId.
|
||||
- **Always wait for Oracle**: When Oracle is running and you have gathered enough context from your own exploration, your next action is \`background_output\` on Oracle — NOT delivering a final answer. Oracle's value is highest when you think you don't need it.
|
||||
- **If Oracle is running**: STOP. Follow Oracle Completion Protocol in <Oracle_Usage>. Do NOT deliver any answer.
|
||||
- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`.
|
||||
- **Never use \`background_cancel(all=true)\`.**
|
||||
</Behavior_Instructions>
|
||||
|
||||
${oracleSection}
|
||||
@@ -548,7 +553,7 @@ export function createSisyphusAgent(
|
||||
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
|
||||
const skills = availableSkills ?? [];
|
||||
const categories = availableCategories ?? [];
|
||||
const prompt = availableAgents
|
||||
let prompt = availableAgents
|
||||
? buildDynamicSisyphusPrompt(
|
||||
model,
|
||||
availableAgents,
|
||||
@@ -559,6 +564,15 @@ export function createSisyphusAgent(
|
||||
)
|
||||
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
|
||||
|
||||
if (isGeminiModel(model)) {
|
||||
prompt = prompt.replace(
|
||||
"</intent_verbalization>",
|
||||
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
|
||||
);
|
||||
prompt += "\n" + buildGeminiDelegationOverride();
|
||||
prompt += "\n" + buildGeminiVerificationOverride();
|
||||
}
|
||||
|
||||
const permission = {
|
||||
question: "allow",
|
||||
call_omo_agent: "deny",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isGptModel } from "./types";
|
||||
import { isGptModel, isGeminiModel } from "./types";
|
||||
|
||||
describe("isGptModel", () => {
|
||||
test("standard openai provider models", () => {
|
||||
@@ -47,3 +47,47 @@ describe("isGptModel", () => {
|
||||
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGeminiModel", () => {
|
||||
test("#given google provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given google-vertex provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given github copilot gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given litellm proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given other proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given gpt models #then returns false", () => {
|
||||
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
|
||||
expect(isGeminiModel("openai/o3-mini")).toBe(false);
|
||||
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
|
||||
});
|
||||
|
||||
test("#given claude models #then returns false", () => {
|
||||
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
|
||||
});
|
||||
|
||||
test("#given opencode provider #then returns false", () => {
|
||||
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,19 @@ export function isGptModel(model: string): boolean {
|
||||
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
|
||||
}
|
||||
|
||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
|
||||
|
||||
export function isGeminiModel(model: string): boolean {
|
||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
|
||||
return true
|
||||
|
||||
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
|
||||
return true
|
||||
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.startsWith("gemini-")
|
||||
}
|
||||
|
||||
export type BuiltinAgentName =
|
||||
| "sisyphus"
|
||||
| "hephaestus"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { stripAnsi } from "./format-shared"
|
||||
import type { DoctorResult } from "./types"
|
||||
|
||||
function createDoctorResult(): DoctorResult {
|
||||
@@ -39,78 +40,122 @@ function createDoctorResult(): DoctorResult {
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatter", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
function createDoctorResultWithIssues(): DoctorResult {
|
||||
const base = createDoctorResult()
|
||||
base.results[1].issues = [
|
||||
{ title: "Config issue", description: "Bad config", severity: "error" as const, fix: "Fix it" },
|
||||
{ title: "Tool warning", description: "Missing tool", severity: "warning" as const },
|
||||
]
|
||||
base.summary.failed = 1
|
||||
base.summary.warnings = 1
|
||||
return base
|
||||
}
|
||||
|
||||
describe("formatDoctorOutput", () => {
|
||||
describe("#given default mode", () => {
|
||||
it("shows System OK when no issues", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?default-ok-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "default"))
|
||||
|
||||
//#then
|
||||
expect(output).toContain("System OK (opencode 1.0.200 · oh-my-opencode 3.4.0)")
|
||||
})
|
||||
|
||||
it("shows issue count and details when issues exist", async () => {
|
||||
//#given
|
||||
const result = createDoctorResultWithIssues()
|
||||
const { formatDoctorOutput } = await import(`./formatter?default-issues-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "default"))
|
||||
|
||||
//#then
|
||||
expect(output).toContain("issues found:")
|
||||
expect(output).toContain("1. Config issue")
|
||||
expect(output).toContain("2. Tool warning")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDoctorOutput", () => {
|
||||
it("dispatches to default formatter for default mode", async () => {
|
||||
describe("#given status mode", () => {
|
||||
it("renders system version line", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "default")
|
||||
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||
|
||||
//#then
|
||||
expect(output).toBe("default-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
|
||||
})
|
||||
|
||||
it("dispatches to status formatter for status mode", async () => {
|
||||
it("renders tool and MCP info", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "status")
|
||||
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||
|
||||
//#then
|
||||
expect(output).toBe("status-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
expect(output).toContain("LSP 2/4")
|
||||
expect(output).toContain("context7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given verbose mode", () => {
|
||||
it("includes all section headers", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?verbose-headers-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
|
||||
|
||||
//#then
|
||||
expect(output).toContain("System Information")
|
||||
expect(output).toContain("Configuration")
|
||||
expect(output).toContain("Tools")
|
||||
expect(output).toContain("MCPs")
|
||||
expect(output).toContain("Summary")
|
||||
})
|
||||
|
||||
it("dispatches to verbose formatter for verbose mode", async () => {
|
||||
it("shows check summary counts", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "verbose")
|
||||
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
|
||||
|
||||
//#then
|
||||
expect(output).toBe("verbose-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
|
||||
expect(output).toContain("1 passed")
|
||||
expect(output).toContain("0 failed")
|
||||
expect(output).toContain("1 warnings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatJsonOutput", () => {
|
||||
it("returns valid JSON payload", async () => {
|
||||
it("returns valid JSON", async () => {
|
||||
//#given
|
||||
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
|
||||
const result = createDoctorResult()
|
||||
const { formatJsonOutput } = await import(`./formatter?json-valid-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = formatJsonOutput(result)
|
||||
|
||||
//#then
|
||||
expect(() => JSON.parse(output)).not.toThrow()
|
||||
})
|
||||
|
||||
it("preserves all result fields", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatJsonOutput } = await import(`./formatter?json-fields-${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const output = formatJsonOutput(result)
|
||||
@@ -119,7 +164,6 @@ describe("formatter", () => {
|
||||
//#then
|
||||
expect(parsed.summary.total).toBe(2)
|
||||
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
|
||||
expect(parsed.tools.ghCli.username).toBe("yeongyu")
|
||||
expect(parsed.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function resolveSession(options: {
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
} as Record<string, unknown>,
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
export {
|
||||
OhMyOpenCodeConfigSchema,
|
||||
AgentOverrideConfigSchema,
|
||||
AgentOverridesSchema,
|
||||
McpNameSchema,
|
||||
AgentNameSchema,
|
||||
HookNameSchema,
|
||||
BuiltinCommandNameSchema,
|
||||
SisyphusAgentConfigSchema,
|
||||
ExperimentalConfigSchema,
|
||||
RalphLoopConfigSchema,
|
||||
TmuxConfigSchema,
|
||||
TmuxLayoutSchema,
|
||||
RuntimeFallbackConfigSchema,
|
||||
FallbackModelsSchema,
|
||||
} from "./schema"
|
||||
|
||||
export type {
|
||||
|
||||
@@ -47,6 +47,12 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
variant: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
compaction: z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
variant: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.object({
|
||||
|
||||
@@ -35,6 +35,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
disabled_tools: z.array(z.string()).optional(),
|
||||
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
|
||||
hashline_edit: z.boolean().optional(),
|
||||
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
|
||||
model_fallback: z.boolean().optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
categories: CategoriesConfigSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ResultHandlerContext } from "./result-handler-context"
|
||||
import { log } from "../../shared"
|
||||
import { notifyParentSession } from "./parent-session-notifier"
|
||||
|
||||
export async function tryCompleteTask(
|
||||
task: BackgroundTask,
|
||||
source: string,
|
||||
ctx: ResultHandlerContext
|
||||
): Promise<boolean> {
|
||||
const { concurrencyManager, state } = ctx
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task already completed, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
source,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
state.markForNotification(task)
|
||||
|
||||
try {
|
||||
await notifyParentSession(task, ctx)
|
||||
log(`[background-agent] Task completed via ${source}:`, task.id)
|
||||
} catch (error) {
|
||||
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
@@ -1,5 +1,2 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
|
||||
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
export { TaskStateManager } from "./state"
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
hasMoreFallbacks,
|
||||
} from "../../shared/model-error-classifier"
|
||||
import {
|
||||
MIN_IDLE_TIME_MS,
|
||||
POLLING_INTERVAL_MS,
|
||||
TASK_CLEANUP_DELAY_MS,
|
||||
} from "./constants"
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
@@ -268,7 +268,7 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
} as any,
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -740,61 +740,15 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
|
||||
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!this.idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
|
||||
const timer = setTimeout(() => {
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
this.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
}, remainingMs)
|
||||
this.idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Edge guard: Verify session has actual assistant output before completing
|
||||
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
|
||||
// Re-check status after async operation (could have been completed by polling)
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
|
||||
|
||||
// Re-check status after async operation again
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await this.tryCompleteTask(task, "session.idle event")
|
||||
}).catch(err => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
if (!props || typeof props !== "object") return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props as Record<string, unknown>,
|
||||
findBySession: (id) => this.findBySession(id),
|
||||
idleDeferralTimers: this.idleDeferralTimers,
|
||||
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
||||
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
||||
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
||||
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { getMessageDir } from "../../shared"
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { OpencodeClient } from "./constants"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { findNearestMessageWithFields } from "../hook-message-injector"
|
||||
import { getMessageDir } from "../../shared"
|
||||
import { normalizePromptTools, resolveInheritedPromptTools } from "../../shared"
|
||||
|
||||
type AgentModel = { providerID: string; modelID: string }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function extractAgentAndModelFromMessage(message: unknown): {
|
||||
agent?: string
|
||||
model?: AgentModel
|
||||
tools?: Record<string, boolean>
|
||||
} {
|
||||
if (!isObject(message)) return {}
|
||||
const info = message["info"]
|
||||
if (!isObject(info)) return {}
|
||||
|
||||
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
|
||||
const modelObj = info["model"]
|
||||
const tools = normalizePromptTools(isObject(info["tools"]) ? info["tools"] as Record<string, unknown> as Record<string, boolean | "allow" | "deny" | "ask"> : undefined)
|
||||
if (isObject(modelObj)) {
|
||||
const providerID = modelObj["providerID"]
|
||||
const modelID = modelObj["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID }, tools }
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = info["providerID"]
|
||||
const modelID = info["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID }, tools }
|
||||
}
|
||||
|
||||
return { agent, tools }
|
||||
}
|
||||
|
||||
export async function resolveParentSessionAgentAndModel(input: {
|
||||
client: OpencodeClient
|
||||
task: BackgroundTask
|
||||
}): Promise<{ agent?: string; model?: AgentModel; tools?: Record<string, boolean> }> {
|
||||
const { client, task } = input
|
||||
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: AgentModel | undefined
|
||||
let tools: Record<string, boolean> | undefined = task.parentTools
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: task.parentSessionID },
|
||||
})
|
||||
|
||||
const messagesRaw = "data" in messagesResp ? messagesResp.data : []
|
||||
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const extracted = extractAgentAndModelFromMessage(messages[i])
|
||||
if (extracted.agent || extracted.model || extracted.tools) {
|
||||
agent = extracted.agent ?? task.parentAgent
|
||||
model = extracted.model
|
||||
tools = extracted.tools ?? tools
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model =
|
||||
currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
tools = normalizePromptTools(currentMessage?.tools) ?? tools
|
||||
}
|
||||
|
||||
return { agent, model, tools: resolveInheritedPromptTools(task.parentSessionID, tools) }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect } = require("bun:test")
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
|
||||
|
||||
describe("notifyParentSession", () => {
|
||||
test("displays INTERRUPTED for interrupted tasks", () => {
|
||||
// given
|
||||
const task: BackgroundTask = {
|
||||
id: "test-task",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
description: "Test task",
|
||||
prompt: "Test prompt",
|
||||
agent: "test-agent",
|
||||
status: "interrupt",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const duration = "1s"
|
||||
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
|
||||
const allComplete = false
|
||||
const remainingCount = 1
|
||||
const completedTasks: BackgroundTask[] = []
|
||||
|
||||
// when
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task,
|
||||
duration,
|
||||
statusText,
|
||||
allComplete,
|
||||
remainingCount,
|
||||
completedTasks,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(notification).toContain("INTERRUPTED")
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ResultHandlerContext } from "./result-handler-context"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { createInternalAgentTextPart, log } from "../../shared"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { formatDuration } from "./duration-formatter"
|
||||
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
|
||||
import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver"
|
||||
|
||||
export async function notifyParentSession(
|
||||
task: BackgroundTask,
|
||||
ctx: ResultHandlerContext
|
||||
): Promise<void> {
|
||||
const { client, state } = ctx
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt)
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.showCompletionToast({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
const pendingSet = state.pendingByParent.get(task.parentSessionID)
|
||||
if (pendingSet) {
|
||||
pendingSet.delete(task.id)
|
||||
if (pendingSet.size === 0) {
|
||||
state.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const allComplete = !pendingSet || pendingSet.size === 0
|
||||
const remainingCount = pendingSet?.size ?? 0
|
||||
|
||||
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
|
||||
|
||||
const completedTasks = allComplete
|
||||
? Array.from(state.tasks.values()).filter(
|
||||
(t) =>
|
||||
t.parentSessionID === task.parentSessionID &&
|
||||
t.status !== "running" &&
|
||||
t.status !== "pending"
|
||||
)
|
||||
: []
|
||||
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task,
|
||||
duration,
|
||||
statusText,
|
||||
allComplete,
|
||||
remainingCount,
|
||||
completedTasks,
|
||||
})
|
||||
|
||||
const { agent, model, tools } = await resolveParentSessionAgentAndModel({ client, task })
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
resolvedAgent: agent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.session.promptAsync({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
})
|
||||
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
if (!allComplete) return
|
||||
|
||||
for (const completedTask of completedTasks) {
|
||||
const taskId = completedTask.id
|
||||
state.clearCompletionTimer(taskId)
|
||||
const timer = setTimeout(() => {
|
||||
state.completionTimers.delete(taskId)
|
||||
if (state.tasks.has(taskId)) {
|
||||
state.clearNotificationsForTask(taskId)
|
||||
state.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
state.setCompletionTimer(taskId, timer)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { OpencodeClient } from "./constants"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { TaskStateManager } from "./state"
|
||||
|
||||
export interface ResultHandlerContext {
|
||||
client: OpencodeClient
|
||||
concurrencyManager: ConcurrencyManager
|
||||
state: TaskStateManager
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type { ResultHandlerContext } from "./result-handler-context"
|
||||
export { formatDuration } from "./duration-formatter"
|
||||
export { getMessageDir } from "../../shared"
|
||||
export { checkSessionTodos } from "./session-todo-checker"
|
||||
export { validateSessionHasOutput } from "./session-output-validator"
|
||||
export { tryCompleteTask } from "./background-task-completer"
|
||||
export { notifyParentSession } from "./parent-session-notifier"
|
||||
340
src/features/background-agent/session-idle-event-handler.test.ts
Normal file
340
src/features/background-agent/session-idle-event-handler.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
|
||||
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
sessionID: "ses-idle-1",
|
||||
parentSessionID: "parent-ses-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "test idle handler",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleSessionIdleBackgroundEvent", () => {
|
||||
describe("#given no sessionID in properties", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: {},
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-string sessionID in properties", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: 123 },
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no task found for session", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: "ses-unknown" },
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task is not running", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const task = createRunningTask({ status: "completed" })
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task has no startedAt", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const task = createRunningTask({ startedAt: undefined })
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
|
||||
it("#when idle fires early #then should defer with timer", () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const emitIdleEvent = mock(() => {})
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(idleDeferralTimers.has(task.id)).toBe(true)
|
||||
expect(emitIdleEvent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clearTimeout(idleDeferralTimers.get(task.id)!)
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
|
||||
it("#when idle already deferred #then should not create duplicate timer", () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const existingTimer = setTimeout(() => {}, 99999)
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
|
||||
[task.id, existingTimer],
|
||||
])
|
||||
const emitIdleEvent = mock(() => {})
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
|
||||
} finally {
|
||||
clearTimeout(existingTimer)
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
|
||||
it("#when deferred timer fires #then should emit idle event", async () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const emitIdleEvent = mock(() => {})
|
||||
const remainingMs = 50
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then - wait for deferred timer
|
||||
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
|
||||
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
|
||||
expect(idleDeferralTimers.has(task.id)).toBe(false)
|
||||
} finally {
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
|
||||
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
|
||||
})
|
||||
|
||||
it("#when session has no valid output #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(false),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task has incomplete todos #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(true),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task status changes during validation #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: async () => {
|
||||
task.status = "completed"
|
||||
return true
|
||||
},
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task status changes during todo check #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: async () => {
|
||||
task.status = "cancelled"
|
||||
return false
|
||||
},
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { OpencodeClient } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
|
||||
type SessionMessagePart = {
|
||||
type?: string
|
||||
text?: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getMessageRole(message: unknown): string | undefined {
|
||||
if (!isObject(message)) return undefined
|
||||
const info = message["info"]
|
||||
if (!isObject(info)) return undefined
|
||||
const role = info["role"]
|
||||
return typeof role === "string" ? role : undefined
|
||||
}
|
||||
|
||||
function getMessageParts(message: unknown): SessionMessagePart[] {
|
||||
if (!isObject(message)) return []
|
||||
const parts = message["parts"]
|
||||
if (!Array.isArray(parts)) return []
|
||||
|
||||
return parts
|
||||
.filter((part): part is SessionMessagePart => isObject(part))
|
||||
.map((part) => ({
|
||||
type: typeof part["type"] === "string" ? part["type"] : undefined,
|
||||
text: typeof part["text"] === "string" ? part["text"] : undefined,
|
||||
content: part["content"],
|
||||
}))
|
||||
}
|
||||
|
||||
function partHasContent(part: SessionMessagePart): boolean {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
return Boolean(part.text && part.text.trim().length > 0)
|
||||
}
|
||||
if (part.type === "tool") return true
|
||||
if (part.type === "tool_result") {
|
||||
if (typeof part.content === "string") return part.content.trim().length > 0
|
||||
if (Array.isArray(part.content)) return part.content.length > 0
|
||||
return Boolean(part.content)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function validateSessionHasOutput(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messagesRaw =
|
||||
isObject(response) && "data" in response ? (response as { data?: unknown }).data : response
|
||||
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
|
||||
|
||||
const hasAssistantOrToolMessage = messages.some((message) => {
|
||||
const role = getMessageRole(message)
|
||||
return role === "assistant" || role === "tool"
|
||||
})
|
||||
|
||||
if (!hasAssistantOrToolMessage) {
|
||||
log("[background-agent] No assistant/tool messages found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
const hasContent = messages.some((message) => {
|
||||
const role = getMessageRole(message)
|
||||
if (role !== "assistant" && role !== "tool") return false
|
||||
const parts = getMessageParts(message)
|
||||
return parts.some(partHasContent)
|
||||
})
|
||||
|
||||
if (!hasContent) {
|
||||
log("[background-agent] Messages exist but no content found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log("[background-agent] Error validating session output:", error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function cleanupTaskAfterSessionEnds(args: {
|
||||
task: BackgroundTask
|
||||
tasks: Map<string, BackgroundTask>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
}): void {
|
||||
const {
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
} = args
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.concurrencyKey && releaseConcurrencyKey) {
|
||||
releaseConcurrencyKey(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
clearNotificationsForTask(task.id)
|
||||
tasks.delete(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { OpencodeClient, Todo } from "./constants"
|
||||
|
||||
function isTodo(value: unknown): value is Todo {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const todo = value as Record<string, unknown>
|
||||
return (
|
||||
(typeof todo["id"] === "string" || todo["id"] === undefined) &&
|
||||
typeof todo["content"] === "string" &&
|
||||
typeof todo["status"] === "string" &&
|
||||
typeof todo["priority"] === "string"
|
||||
)
|
||||
}
|
||||
|
||||
export async function checkSessionTodos(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const todosRaw = "data" in response ? response.data : response
|
||||
if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false
|
||||
|
||||
const incomplete = todosRaw
|
||||
.filter(isTodo)
|
||||
.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
|
||||
return incomplete.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -61,9 +61,7 @@ export async function startTask(
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { OpencodeClient } from "../constants"
|
||||
import type { ConcurrencyManager } from "../concurrency"
|
||||
import type { LaunchInput } from "../types"
|
||||
import { log } from "../../../shared"
|
||||
|
||||
export async function createBackgroundSession(options: {
|
||||
client: OpencodeClient
|
||||
input: LaunchInput
|
||||
parentDirectory: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
concurrencyKey: string
|
||||
}): Promise<string> {
|
||||
const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options
|
||||
|
||||
const body = {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
}
|
||||
|
||||
const createResult = await client.session
|
||||
.create({
|
||||
body,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
concurrencyManager.release(concurrencyKey)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
concurrencyManager.release(concurrencyKey)
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
if (!createResult.data?.id) {
|
||||
concurrencyManager.release(concurrencyKey)
|
||||
throw new Error("Failed to create background session: API returned no session ID")
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
log("[background-agent] Background session created", { sessionID })
|
||||
return sessionID
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { LaunchInput } from "../types"
|
||||
|
||||
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
|
||||
return input.model
|
||||
? `${input.model.providerID}/${input.model.modelID}`
|
||||
: input.agent
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { BackgroundTask } from "../types"
|
||||
import type { ConcurrencyManager } from "../concurrency"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated } from "../constants"
|
||||
|
||||
export interface SpawnerContext {
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
tmuxEnabled: boolean
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onTaskError: (task: BackgroundTask, error: Error) => void
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { setTimeout } from "timers/promises"
|
||||
import type { OnSubagentSessionCreated } from "../constants"
|
||||
import { TMUX_CALLBACK_DELAY_MS } from "../constants"
|
||||
import { log } from "../../../shared"
|
||||
import { isInsideTmux } from "../../../shared/tmux"
|
||||
|
||||
export async function maybeInvokeTmuxCallback(options: {
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
tmuxEnabled: boolean
|
||||
sessionID: string
|
||||
parentID: string
|
||||
title: string
|
||||
}): Promise<void> {
|
||||
const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID,
|
||||
})
|
||||
|
||||
if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
return
|
||||
}
|
||||
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID,
|
||||
title,
|
||||
}).catch((error: unknown) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", error)
|
||||
})
|
||||
|
||||
log("[background-agent] tmux callback completed, waiting")
|
||||
await setTimeout(TMUX_CALLBACK_DELAY_MS)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./types"
|
||||
export * from "./storage"
|
||||
export * from "./session-storage"
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./schema"
|
||||
export * from "./oauth-authorization-flow"
|
||||
export * from "./provider"
|
||||
@@ -4,6 +4,12 @@ import type { ConcurrencyManager } from "../background-agent/concurrency"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
type ClientWithTui = {
|
||||
tui?: {
|
||||
showToast: (opts: { body: { title: string; message: string; variant: string; duration: number } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskToastManager {
|
||||
private tasks: Map<string, TrackedTask> = new Map()
|
||||
private client: OpencodeClient
|
||||
@@ -170,8 +176,7 @@ export class TaskToastManager {
|
||||
* Show consolidated toast with all running/queued tasks
|
||||
*/
|
||||
private showTaskListToast(newTask: TrackedTask): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
const tuiClient = this.client as ClientWithTui
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
const message = this.buildTaskListMessage(newTask)
|
||||
@@ -196,8 +201,7 @@ export class TaskToastManager {
|
||||
* Show task completion toast
|
||||
*/
|
||||
showCompletionToast(task: { id: string; description: string; duration: string }): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
const tuiClient = this.client as ClientWithTui
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
this.removeTask(task.id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AutoCompactState } from "./types";
|
||||
import type { OhMyOpenCodeConfig } from "../../config";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { TRUNCATE_CONFIG } from "./types";
|
||||
|
||||
@@ -15,15 +16,15 @@ export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
client: Client,
|
||||
directory: string,
|
||||
experimental?: ExperimentalConfig,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
_experimental?: ExperimentalConfig
|
||||
): Promise<void> {
|
||||
void experimental
|
||||
void _experimental
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await (client as Client).tui
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Compact In Progress",
|
||||
@@ -55,7 +56,7 @@ export async function executeCompact(
|
||||
const result = await runAggressiveTruncationStrategy({
|
||||
sessionID,
|
||||
autoCompactState,
|
||||
client: client as Client,
|
||||
client: client,
|
||||
directory,
|
||||
truncateAttempt: truncateState.truncateAttempt,
|
||||
currentTokens: errorData.currentTokens,
|
||||
@@ -70,8 +71,9 @@ export async function executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client: client as Client,
|
||||
client: client,
|
||||
directory,
|
||||
pluginConfig,
|
||||
errorType: errorData?.errorType,
|
||||
messageIndex: errorData?.messageIndex,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Client } from "./client"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||
@@ -8,6 +9,7 @@ import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
@@ -28,6 +30,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
const pluginConfig = options?.pluginConfig!
|
||||
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
@@ -89,8 +92,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.client as Client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}, 300)
|
||||
@@ -156,8 +160,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.client as Client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { RETRY_CONFIG } from "./types"
|
||||
import type { Client } from "./client"
|
||||
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
|
||||
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
|
||||
import { fixEmptyMessages } from "./empty-content-recovery"
|
||||
|
||||
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
||||
export async function runSummarizeRetryStrategy(params: {
|
||||
sessionID: string
|
||||
msg: Record<string, unknown>
|
||||
autoCompactState: AutoCompactState
|
||||
client: Client
|
||||
directory: string
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
errorType?: string
|
||||
messageIndex?: number
|
||||
}): Promise<void> {
|
||||
@@ -74,7 +77,14 @@ export async function runSummarizeRetryStrategy(params: {
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||
params.pluginConfig,
|
||||
params.sessionID,
|
||||
providerID,
|
||||
modelID
|
||||
)
|
||||
|
||||
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
|
||||
await params.client.session.summarize({
|
||||
path: { id: params.sessionID },
|
||||
body: summarizeBody as never,
|
||||
|
||||
@@ -104,6 +104,65 @@ ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CER
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
|
||||
|
||||
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
|
||||
|
||||
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
|
||||
They ROUTINELY:
|
||||
- Ship code with syntax errors they didn't bother to check
|
||||
- Create stub implementations with TODOs and call it "done"
|
||||
- Write tests that pass trivially (testing nothing meaningful)
|
||||
- Implement logic that does NOT match what was requested
|
||||
- Add features nobody asked for and call it "improvement"
|
||||
- Report "all tests pass" when they didn't run any tests
|
||||
|
||||
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
|
||||
|
||||
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
|
||||
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
|
||||
|
||||
---
|
||||
|
||||
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
|
||||
|
||||
Read the code FIRST so you know what you're testing.
|
||||
|
||||
1. \`Bash("git diff --stat")\` — see exactly which files changed.
|
||||
2. \`Read\` EVERY changed file — no exceptions, no skimming.
|
||||
3. For EACH file:
|
||||
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
|
||||
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
|
||||
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
|
||||
- Scope creep? Did the subagent add things NOT in the task spec?
|
||||
4. Cross-check EVERY claim against actual code.
|
||||
|
||||
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
|
||||
|
||||
**PHASE 2: RUN AUTOMATED CHECKS**
|
||||
|
||||
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
|
||||
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
|
||||
3. Build/typecheck — exit 0.
|
||||
|
||||
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
|
||||
|
||||
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
|
||||
|
||||
- **Frontend/UI**: \`/playwright\`
|
||||
- **TUI/CLI**: \`interactive_bash\`
|
||||
- **API/Backend**: \`Bash\` with curl
|
||||
|
||||
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
|
||||
|
||||
**PHASE 4: GATE DECISION**
|
||||
|
||||
1. Can I explain what EVERY changed line does? (If no → Phase 1)
|
||||
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
|
||||
3. Am I confident nothing is broken? (If no → broader tests)
|
||||
|
||||
ALL three must be YES. "Probably" = NO. "I think so" = NO.
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete.**`
|
||||
|
||||
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, it, expect, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
const mockShowConfigErrorsIfAny = mock(async () => {})
|
||||
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
||||
@@ -7,7 +7,7 @@ const mockShowLocalDevToast = mock(async () => {})
|
||||
const mockShowVersionToast = mock(async () => {})
|
||||
const mockRunBackgroundUpdateCheck = mock(async () => {})
|
||||
const mockGetCachedVersion = mock(() => "3.6.0")
|
||||
const mockGetLocalDevVersion = mock(() => "3.6.0")
|
||||
const mockGetLocalDevVersion = mock<(directory: string) => string | null>(() => null)
|
||||
|
||||
mock.module("./hook/config-errors-toast", () => ({
|
||||
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
|
||||
@@ -40,31 +40,49 @@ mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
const { createAutoUpdateCheckerHook } = await import("./hook")
|
||||
type HookFactory = typeof import("./hook").createAutoUpdateCheckerHook
|
||||
|
||||
async function importFreshHookFactory(): Promise<HookFactory> {
|
||||
const hookModule = await import(`./hook?test-${Date.now()}-${Math.random()}`)
|
||||
return hookModule.createAutoUpdateCheckerHook
|
||||
}
|
||||
|
||||
function createPluginInput() {
|
||||
return {
|
||||
directory: "/test",
|
||||
client: {} as never,
|
||||
} as never
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
||||
mockShowLocalDevToast.mockClear()
|
||||
mockShowVersionToast.mockClear()
|
||||
mockRunBackgroundUpdateCheck.mockClear()
|
||||
mockGetCachedVersion.mockClear()
|
||||
mockGetLocalDevVersion.mockClear()
|
||||
|
||||
mockGetCachedVersion.mockReturnValue("3.6.0")
|
||||
mockGetLocalDevVersion.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCODE_CLI_RUN_MODE
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("createAutoUpdateCheckerHook", () => {
|
||||
it("skips startup toasts and checks in CLI run mode", async () => {
|
||||
//#given - CLI run mode enabled
|
||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
||||
mockShowLocalDevToast.mockClear()
|
||||
mockShowVersionToast.mockClear()
|
||||
mockRunBackgroundUpdateCheck.mockClear()
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
|
||||
const hook = createAutoUpdateCheckerHook(
|
||||
{
|
||||
directory: "/test",
|
||||
client: {} as never,
|
||||
} as never,
|
||||
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
|
||||
)
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
|
||||
showStartupToast: true,
|
||||
isSisyphusEnabled: true,
|
||||
autoUpdate: true,
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
@@ -73,7 +91,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
properties: { info: { parentID: undefined } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - no update checker side effects run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -82,6 +100,144 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("runs all startup checks on normal session.created", async () => {
|
||||
//#given - normal mode and no local dev version
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives on primary session
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - startup checks, toast, and background check run
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("ignores subagent sessions (parentID present)", async () => {
|
||||
//#given - a subagent session with parentID
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event contains parentID
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { parentID: "parent-123" } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("runs only once (hasChecked guard)", async () => {
|
||||
//#given - one hook instance in normal mode
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event is fired twice
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - side effects execute only once
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("shows localDevToast when local dev version exists", async () => {
|
||||
//#given - local dev version is present
|
||||
mockGetLocalDevVersion.mockReturnValue("3.6.0-dev")
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - local dev toast is shown and background check is skipped
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("ignores non-session.created events", async () => {
|
||||
//#given - a hook instance in normal mode
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - a non-session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("passes correct toast message with sisyphus enabled", async () => {
|
||||
//#given - sisyphus mode enabled
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
|
||||
isSisyphusEnabled: true,
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then - startup toast includes sisyphus wording
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"3.6.0",
|
||||
expect.stringContaining("Sisyphus")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,177 +1,208 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
// Mock modules before importing
|
||||
const mockFindPluginEntry = mock(() => null as any)
|
||||
const mockGetCachedVersion = mock(() => null as string | null)
|
||||
const mockGetLatestVersion = mock(async () => null as string | null)
|
||||
const mockUpdatePinnedVersion = mock(() => false)
|
||||
type PluginEntry = {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
|
||||
|
||||
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
|
||||
return {
|
||||
entry: "oh-my-opencode@3.4.0",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
|
||||
const mockGetCachedVersion = mock((): string | null => "3.4.0")
|
||||
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
|
||||
const mockExtractChannel = mock(() => "latest")
|
||||
const mockInvalidatePackage = mock(() => {})
|
||||
const mockRunBunInstall = mock(async () => true)
|
||||
const mockShowUpdateAvailableToast = mock(async () => {})
|
||||
const mockShowAutoUpdatedToast = mock(async () => {})
|
||||
const mockShowUpdateAvailableToast = mock(
|
||||
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
|
||||
)
|
||||
const mockShowAutoUpdatedToast = mock(
|
||||
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||
)
|
||||
|
||||
mock.module("../checker", () => ({
|
||||
findPluginEntry: mockFindPluginEntry,
|
||||
getCachedVersion: mockGetCachedVersion,
|
||||
getLatestVersion: mockGetLatestVersion,
|
||||
updatePinnedVersion: mockUpdatePinnedVersion,
|
||||
revertPinnedVersion: mock(() => false),
|
||||
}))
|
||||
|
||||
mock.module("../version-channel", () => ({
|
||||
extractChannel: mockExtractChannel,
|
||||
}))
|
||||
|
||||
mock.module("../cache", () => ({
|
||||
invalidatePackage: mockInvalidatePackage,
|
||||
}))
|
||||
|
||||
mock.module("../../../cli/config-manager", () => ({
|
||||
runBunInstall: mockRunBunInstall,
|
||||
}))
|
||||
|
||||
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
|
||||
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
|
||||
mock.module("../../../cli/config-manager", () => ({ runBunInstall: mockRunBunInstall }))
|
||||
mock.module("./update-toasts", () => ({
|
||||
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
||||
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
||||
}))
|
||||
mock.module("../../../shared/logger", () => ({ log: () => {} }))
|
||||
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
|
||||
const modulePath = "./background-update-check?test"
|
||||
const { runBackgroundUpdateCheck } = await import(modulePath)
|
||||
|
||||
describe("runBackgroundUpdateCheck", () => {
|
||||
const mockCtx = { directory: "/test" } as any
|
||||
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
|
||||
const mockCtx = { directory: "/test" } as PluginInput
|
||||
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
|
||||
isUpdate ? `Update to ${version}` : "Up to date"
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReset()
|
||||
mockGetCachedVersion.mockReset()
|
||||
mockGetLatestVersion.mockReset()
|
||||
mockUpdatePinnedVersion.mockReset()
|
||||
mockExtractChannel.mockReset()
|
||||
mockInvalidatePackage.mockReset()
|
||||
mockRunBunInstall.mockReset()
|
||||
mockShowUpdateAvailableToast.mockReset()
|
||||
mockShowAutoUpdatedToast.mockReset()
|
||||
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry())
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
mockExtractChannel.mockReturnValue("latest")
|
||||
mockRunBunInstall.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe("#given user has pinned a specific version", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode@3.4.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "3.4.0",
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should NOT call updatePinnedVersion", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should show manual-update toast message", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [toastContext, latestVersion, getToastMessage] = mockShowUpdateAvailableToast.mock.calls[0] ?? []
|
||||
expect(toastContext).toBe(mockCtx)
|
||||
expect(latestVersion).toBe("3.5.0")
|
||||
expect(typeof getToastMessage).toBe("function")
|
||||
expect(getToastMessage(true, "3.5.0")).toBe("Update available: 3.5.0 (version pinned, update manually)")
|
||||
})
|
||||
|
||||
it("#then should NOT run bun install", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
describe("#given no plugin entry found", () => {
|
||||
it("returns early without showing any toast", async () => {
|
||||
//#given
|
||||
mockFindPluginEntry.mockReturnValue(null)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should NOT invalidate package cache", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockInvalidatePackage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user has NOT pinned a version (unpinned)", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should proceed with auto-update", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockInvalidatePackage).toHaveBeenCalled()
|
||||
expect(mockRunBunInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should show auto-updated toast on success", async () => {
|
||||
mockRunBunInstall.mockResolvedValue(true)
|
||||
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given autoUpdate is false", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should only show notification toast", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
|
||||
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given already on latest version", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode@3.5.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "3.5.0",
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.5.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should not update or show toast", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
describe("#given no version available", () => {
|
||||
it("returns early when neither cached nor pinned version exists", async () => {
|
||||
//#given
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
|
||||
mockGetCachedVersion.mockReturnValue(null)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetLatestVersion).not.toHaveBeenCalled()
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given latest version fetch fails", () => {
|
||||
it("returns early without toasts", async () => {
|
||||
//#given
|
||||
mockGetLatestVersion.mockResolvedValue(null)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given already on latest version", () => {
|
||||
it("returns early without any action", async () => {
|
||||
//#given
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.4.0")
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given update available with autoUpdate disabled", () => {
|
||||
it("shows update notification but does not install", async () => {
|
||||
//#given
|
||||
const autoUpdate = false
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
|
||||
//#then
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user has pinned a specific version", () => {
|
||||
it("shows pinned-version toast without auto-updating", async () => {
|
||||
//#given
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("toast message mentions version pinned", async () => {
|
||||
//#given
|
||||
let capturedToastMessage: ToastMessageGetter | undefined
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
|
||||
mockShowUpdateAvailableToast.mockImplementation(
|
||||
async (_ctx: PluginInput, _latestVersion: string, toastMessage: ToastMessageGetter) => {
|
||||
capturedToastMessage = toastMessage
|
||||
}
|
||||
)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
|
||||
expect(capturedToastMessage).toBeDefined()
|
||||
if (!capturedToastMessage) {
|
||||
throw new Error("toast message callback missing")
|
||||
}
|
||||
const message = capturedToastMessage(true, "3.5.0")
|
||||
expect(message).toContain("version pinned")
|
||||
expect(message).not.toBe("Update to 3.5.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given unpinned with auto-update and install succeeds", () => {
|
||||
it("invalidates cache, installs, and shows auto-updated toast", async () => {
|
||||
//#given
|
||||
mockRunBunInstall.mockResolvedValue(true)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given unpinned with auto-update and install fails", () => {
|
||||
it("falls back to notification-only toast", async () => {
|
||||
//#given
|
||||
mockRunBunInstall.mockResolvedValue(false)
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
//#then
|
||||
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,161 +1,204 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
|
||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
const storageMaps = new Map<string, Set<string>>()
|
||||
|
||||
mock.module("./constants", () => ({
|
||||
AGENTS_INJECTOR_STORAGE: "/tmp/directory-agents-injector-tests",
|
||||
AGENTS_FILENAME: "AGENTS.md",
|
||||
}))
|
||||
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
|
||||
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
|
||||
storageMaps.set(sessionID, paths)
|
||||
},
|
||||
clearInjectedPaths: (sessionID: string) => {
|
||||
storageMaps.delete(sessionID)
|
||||
},
|
||||
}))
|
||||
|
||||
const truncator = {
|
||||
truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false }),
|
||||
getUsage: async (_sessionID: string) => null,
|
||||
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
|
||||
result: output,
|
||||
truncated: false,
|
||||
}),
|
||||
}
|
||||
|
||||
describe("processFilePathForAgentsInjection", () => {
|
||||
let testRoot = ""
|
||||
let srcDirectory = ""
|
||||
let componentsDirectory = ""
|
||||
|
||||
const rootAgentsContent = "# ROOT AGENTS\nroot-level directives"
|
||||
const srcAgentsContent = "# SRC AGENTS\nsrc-level directives"
|
||||
const componentsAgentsContent = "# COMPONENT AGENTS\ncomponents-level directives"
|
||||
|
||||
beforeEach(() => {
|
||||
findAgentsMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
storageMaps.clear()
|
||||
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
testRoot = join(tmpdir(), `directory-agents-injector-${randomUUID()}`)
|
||||
srcDirectory = join(testRoot, "src")
|
||||
componentsDirectory = join(srcDirectory, "components")
|
||||
|
||||
mkdirSync(componentsDirectory, { recursive: true })
|
||||
writeFileSync(join(testRoot, "AGENTS.md"), rootAgentsContent)
|
||||
writeFileSync(join(srcDirectory, "AGENTS.md"), srcAgentsContent)
|
||||
writeFileSync(join(componentsDirectory, "AGENTS.md"), componentsAgentsContent)
|
||||
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true\n")
|
||||
writeFileSync(join(srcDirectory, "file.ts"), "export const sourceFile = true\n")
|
||||
writeFileSync(join(testRoot, "file.ts"), "export const rootFile = true\n")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not save when all discovered paths are already cached", async () => {
|
||||
//#given
|
||||
const sessionID = "session-1"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const agentsPath = join(repoRoot, "src", "AGENTS.md")
|
||||
const cachedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(agentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
it("injects AGENTS.md content from file's parent directory into output", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
filePath: join(srcDirectory, "file.ts"),
|
||||
sessionID: "session-parent",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
||||
// then
|
||||
expect(output.output).toContain("[Directory Context:")
|
||||
expect(output.output).toContain(srcAgentsContent)
|
||||
})
|
||||
|
||||
it("saves when a new path is injected", async () => {
|
||||
//#given
|
||||
const sessionID = "session-2"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const agentsPath = join(repoRoot, "src", "AGENTS.md")
|
||||
const injectedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(agentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set())
|
||||
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
it("skips root-level AGENTS.md", async () => {
|
||||
// given
|
||||
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
|
||||
rmSync(join(componentsDirectory, "AGENTS.md"), { force: true })
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
filePath: join(testRoot, "file.ts"),
|
||||
sessionID: "session-root-skip",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect(saveCall[0]).toBe(sessionID)
|
||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
||||
// then
|
||||
expect(output.output).not.toContain(rootAgentsContent)
|
||||
expect(output.output).not.toContain("[Directory Context:")
|
||||
})
|
||||
|
||||
it("saves once when cached and new paths are mixed", async () => {
|
||||
//#given
|
||||
const sessionID = "session-3"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md")
|
||||
const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md")
|
||||
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
|
||||
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
|
||||
writeFileSync(cachedAgentsPath, "# AGENTS")
|
||||
writeFileSync(newAgentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
it("injects multiple AGENTS.md when walking up directory tree", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-multiple",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
// then
|
||||
expect(output.output).toContain(srcAgentsContent)
|
||||
expect(output.output).toContain(componentsAgentsContent)
|
||||
})
|
||||
|
||||
it("does not re-inject already cached directories", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const sessionCaches = new Map<string, Set<string>>()
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-cache",
|
||||
output,
|
||||
})
|
||||
const outputAfterFirstCall = output.output
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-cache",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(outputAfterFirstCall)
|
||||
expect(output.output.split("[Directory Context:").length - 1).toBe(2)
|
||||
})
|
||||
|
||||
it("shows truncation notice when content is truncated", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
const truncatedTruncator = {
|
||||
truncate: async (_sessionID: string, _content: string) => ({
|
||||
result: "truncated...",
|
||||
truncated: true,
|
||||
}),
|
||||
getUsage: async (_sessionID: string) => null,
|
||||
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
|
||||
result: output,
|
||||
truncated: false,
|
||||
}),
|
||||
}
|
||||
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator: truncatedTruncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(srcDirectory, "file.ts"),
|
||||
sessionID: "session-truncated",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("truncated...")
|
||||
expect(output.output).toContain("[Note: Content was truncated")
|
||||
})
|
||||
|
||||
it("does nothing when filePath cannot be resolved", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "",
|
||||
sessionID: "session-empty-path",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("base output")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,161 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
|
||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const storageMaps = new Map<string, Set<string>>()
|
||||
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
|
||||
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
|
||||
storageMaps.set(sessionID, paths)
|
||||
},
|
||||
}))
|
||||
|
||||
function createPluginContext(directory: string): PluginInput {
|
||||
return { directory } as PluginInput
|
||||
}
|
||||
|
||||
function countReadmeMarkers(output: string): number {
|
||||
return output.split("[Project README:").length - 1
|
||||
}
|
||||
|
||||
function createTruncator(input?: { truncated?: boolean; result?: string }) {
|
||||
return {
|
||||
truncate: async (_sessionID: string, content: string) => ({
|
||||
result: input?.result ?? content,
|
||||
truncated: input?.truncated ?? false,
|
||||
}),
|
||||
getUsage: async (_sessionID: string) => null,
|
||||
truncateSync: (output: string) => ({ result: output, truncated: false }),
|
||||
}
|
||||
}
|
||||
|
||||
describe("processFilePathForReadmeInjection", () => {
|
||||
let testRoot = ""
|
||||
|
||||
beforeEach(() => {
|
||||
findReadmeMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
storageMaps.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
storageMaps.clear()
|
||||
})
|
||||
|
||||
it("does not save when all discovered paths are already cached", async () => {
|
||||
//#given
|
||||
const sessionID = "session-1"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const readmePath = join(repoRoot, "src", "README.md")
|
||||
const cachedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(readmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
|
||||
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
it("injects README.md content from file's parent directory into output", async () => {
|
||||
// given
|
||||
const sourceDirectory = join(testRoot, "src")
|
||||
mkdirSync(sourceDirectory, { recursive: true })
|
||||
writeFileSync(join(sourceDirectory, "README.md"), "# Source README\nlocal context")
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "base", metadata: {} }
|
||||
const truncator = createTruncator()
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(sourceDirectory, "file.ts"),
|
||||
sessionID: "session-parent",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
||||
// then
|
||||
expect(output.output).toContain("[Project README:")
|
||||
expect(output.output).toContain("# Source README")
|
||||
expect(output.output).toContain("local context")
|
||||
})
|
||||
|
||||
it("saves when a new path is injected", async () => {
|
||||
//#given
|
||||
const sessionID = "session-2"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const readmePath = join(repoRoot, "src", "README.md")
|
||||
const injectedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(readmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set())
|
||||
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
it("includes root-level README.md (unlike agents-injector)", async () => {
|
||||
// given
|
||||
writeFileSync(join(testRoot, "README.md"), "# Root README\nroot context")
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "", metadata: {} }
|
||||
const truncator = createTruncator()
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(testRoot, "file.ts"),
|
||||
sessionID: "session-root",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect(saveCall[0]).toBe(sessionID)
|
||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
||||
// then
|
||||
expect(output.output).toContain("[Project README:")
|
||||
expect(output.output).toContain("# Root README")
|
||||
expect(output.output).toContain("root context")
|
||||
})
|
||||
|
||||
it("saves once when cached and new paths are mixed", async () => {
|
||||
//#given
|
||||
const sessionID = "session-3"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
|
||||
const newReadmePath = join(repoRoot, "new-dir", "README.md")
|
||||
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
|
||||
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
|
||||
writeFileSync(cachedReadmePath, "# README")
|
||||
writeFileSync(newReadmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
|
||||
findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
it("injects multiple README.md when walking up directory tree", async () => {
|
||||
// given
|
||||
const sourceDirectory = join(testRoot, "src")
|
||||
const componentsDirectory = join(sourceDirectory, "components")
|
||||
mkdirSync(componentsDirectory, { recursive: true })
|
||||
writeFileSync(join(testRoot, "README.md"), "# Root README")
|
||||
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
|
||||
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
|
||||
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true")
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "", metadata: {} }
|
||||
const truncator = createTruncator()
|
||||
|
||||
//#when
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-multi",
|
||||
output,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
// then
|
||||
expect(countReadmeMarkers(output.output)).toBe(3)
|
||||
expect(output.output).toContain("# Root README")
|
||||
expect(output.output).toContain("# Src README")
|
||||
expect(output.output).toContain("# Components README")
|
||||
})
|
||||
|
||||
it("does not re-inject already cached directories", async () => {
|
||||
// given
|
||||
const sourceDirectory = join(testRoot, "src")
|
||||
mkdirSync(sourceDirectory, { recursive: true })
|
||||
writeFileSync(join(sourceDirectory, "README.md"), "# Source README")
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const sessionCaches = new Map<string, Set<string>>()
|
||||
const sessionID = "session-cache"
|
||||
const truncator = createTruncator()
|
||||
const firstOutput = { title: "Result", output: "", metadata: {} }
|
||||
const secondOutput = { title: "Result", output: "", metadata: {} }
|
||||
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: join(sourceDirectory, "a.ts"),
|
||||
sessionID,
|
||||
output: firstOutput,
|
||||
})
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: join(sourceDirectory, "b.ts"),
|
||||
sessionID,
|
||||
output: secondOutput,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(countReadmeMarkers(firstOutput.output)).toBe(1)
|
||||
expect(secondOutput.output).toBe("")
|
||||
})
|
||||
|
||||
it("shows truncation notice when content is truncated", async () => {
|
||||
// given
|
||||
const sourceDirectory = join(testRoot, "src")
|
||||
mkdirSync(sourceDirectory, { recursive: true })
|
||||
writeFileSync(join(sourceDirectory, "README.md"), "# Truncated README")
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "", metadata: {} }
|
||||
const truncator = createTruncator({ result: "trimmed content", truncated: true })
|
||||
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(sourceDirectory, "file.ts"),
|
||||
sessionID: "session-truncated",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("trimmed content")
|
||||
expect(output.output).toContain("[Note: Content was truncated")
|
||||
})
|
||||
|
||||
it("does nothing when filePath cannot be resolved", async () => {
|
||||
// given
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "unchanged", metadata: {} }
|
||||
const truncator = createTruncator()
|
||||
|
||||
// when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: "",
|
||||
sessionID: "session-empty-path",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("unchanged")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createHashlineEditDiffEnhancerHook } from "./hook"
|
||||
|
||||
function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") {
|
||||
return { tool, sessionID, callID }
|
||||
}
|
||||
|
||||
function makeBeforeOutput(args: Record<string, unknown>) {
|
||||
return { args }
|
||||
}
|
||||
|
||||
function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record<string, unknown> }>) {
|
||||
return {
|
||||
title: overrides?.title ?? "",
|
||||
output: overrides?.output ?? "Successfully applied 1 edit(s)",
|
||||
metadata: overrides?.metadata ?? { truncated: false },
|
||||
}
|
||||
}
|
||||
|
||||
type FileDiffMetadata = {
|
||||
file: string
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
describe("hashline-edit-diff-enhancer", () => {
|
||||
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } })
|
||||
})
|
||||
|
||||
describe("tool.execute.before", () => {
|
||||
test("captures old file content for write tool", async () => {
|
||||
const filePath = import.meta.dir + "/index.test.ts"
|
||||
const input = makeInput("write")
|
||||
const output = makeBeforeOutput({ path: filePath, edits: [] })
|
||||
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// given the hook ran without error, the old content should be stored internally
|
||||
// we verify in the after hook test that it produces filediff
|
||||
})
|
||||
|
||||
test("ignores non-write tools", async () => {
|
||||
const input = makeInput("read")
|
||||
const output = makeBeforeOutput({ path: "/some/file.ts" })
|
||||
|
||||
// when - should not throw
|
||||
await hook["tool.execute.before"](input, output)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
test("injects filediff metadata after write tool execution", async () => {
|
||||
// given - a temp file that we can modify between before/after
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
|
||||
const oldContent = "line 1\nline 2\nline 3\n"
|
||||
await Bun.write(tmpFile, oldContent)
|
||||
|
||||
const input = makeInput("write", "call-diff-1")
|
||||
const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
|
||||
|
||||
// when - before hook captures old content
|
||||
await hook["tool.execute.before"](input, beforeOutput)
|
||||
|
||||
// when - file is modified (simulating write execution)
|
||||
const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
|
||||
await Bun.write(tmpFile, newContent)
|
||||
|
||||
// when - after hook computes filediff
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
// then - metadata should contain filediff
|
||||
const filediff = afterOutput.metadata.filediff as {
|
||||
file: string
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
expect(filediff).toBeDefined()
|
||||
expect(filediff.file).toBe(tmpFile)
|
||||
expect(filediff.path).toBe(tmpFile)
|
||||
expect(filediff.before).toBe(oldContent)
|
||||
expect(filediff.after).toBe(newContent)
|
||||
expect(filediff.additions).toBeGreaterThan(0)
|
||||
expect(filediff.deletions).toBeGreaterThan(0)
|
||||
|
||||
// then - title should be set to the file path
|
||||
expect(afterOutput.title).toBe(tmpFile)
|
||||
|
||||
// cleanup
|
||||
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
|
||||
})
|
||||
|
||||
test("does nothing for non-write tools", async () => {
|
||||
const input = makeInput("read", "call-other")
|
||||
const afterOutput = makeAfterOutput()
|
||||
const originalMetadata = { ...afterOutput.metadata }
|
||||
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
// then - metadata unchanged
|
||||
expect(afterOutput.metadata).toEqual(originalMetadata)
|
||||
})
|
||||
|
||||
test("does nothing when no before capture exists", async () => {
|
||||
// given - no before hook was called for this callID
|
||||
const input = makeInput("write", "call-no-before")
|
||||
const afterOutput = makeAfterOutput()
|
||||
const originalMetadata = { ...afterOutput.metadata }
|
||||
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
// then - metadata unchanged (no filediff injected)
|
||||
expect(afterOutput.metadata.filediff).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cleans up stored content after consumption", async () => {
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
|
||||
await Bun.write(tmpFile, "original")
|
||||
|
||||
const input = makeInput("write", "call-cleanup")
|
||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
||||
await Bun.write(tmpFile, "modified")
|
||||
|
||||
// when - first after call consumes
|
||||
const afterOutput1 = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput1)
|
||||
expect(afterOutput1.metadata.filediff).toBeDefined()
|
||||
|
||||
// when - second after call finds nothing
|
||||
const afterOutput2 = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput2)
|
||||
expect(afterOutput2.metadata.filediff).toBeUndefined()
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
|
||||
test("handles file creation (empty old content)", async () => {
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
|
||||
|
||||
// given - file doesn't exist during before hook
|
||||
const input = makeInput("write", "call-create")
|
||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
||||
|
||||
// when - file created during write
|
||||
await Bun.write(tmpFile, "new content\n")
|
||||
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
// then - filediff shows creation (before is empty)
|
||||
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
|
||||
expect(filediff).toBeDefined()
|
||||
expect(filediff.before).toBe("")
|
||||
expect(filediff.after).toBe("new content\n")
|
||||
expect(filediff.additions).toBeGreaterThan(0)
|
||||
expect(filediff.deletions).toBe(0)
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("disabled config", () => {
|
||||
test("does nothing when hashline_edit is disabled", async () => {
|
||||
const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } })
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
|
||||
await Bun.write(tmpFile, "content")
|
||||
|
||||
const input = makeInput("write", "call-disabled")
|
||||
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
||||
await Bun.write(tmpFile, "modified")
|
||||
|
||||
const afterOutput = makeAfterOutput()
|
||||
await disabledHook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
// then - no filediff injected
|
||||
expect(afterOutput.metadata.filediff).toBeUndefined()
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("write tool support", () => {
|
||||
test("captures filediff for write tool (path arg)", async () => {
|
||||
//#given - a temp file
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts`
|
||||
const oldContent = "line 1\nline 2\n"
|
||||
await Bun.write(tmpFile, oldContent)
|
||||
|
||||
const input = makeInput("write", "call-write-1")
|
||||
const beforeOutput = makeBeforeOutput({ path: tmpFile })
|
||||
|
||||
//#when - before hook captures old content
|
||||
await hook["tool.execute.before"](input, beforeOutput)
|
||||
|
||||
//#when - file is written
|
||||
const newContent = "line 1\nmodified line 2\nnew line 3\n"
|
||||
await Bun.write(tmpFile, newContent)
|
||||
|
||||
//#when - after hook computes filediff
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
//#then - metadata should contain filediff
|
||||
const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number }
|
||||
expect(filediff).toBeDefined()
|
||||
expect(filediff.file).toBe(tmpFile)
|
||||
expect(filediff.additions).toBeGreaterThan(0)
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
|
||||
test("captures filediff for write tool (filePath arg)", async () => {
|
||||
//#given
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts`
|
||||
await Bun.write(tmpFile, "original content\n")
|
||||
|
||||
const input = makeInput("write", "call-write-fp")
|
||||
|
||||
//#when - before hook uses filePath arg
|
||||
await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile }))
|
||||
await Bun.write(tmpFile, "new content\n")
|
||||
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
//#then
|
||||
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
|
||||
expect(filediff).toBeDefined()
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("raw content in filediff", () => {
|
||||
test("filediff.before and filediff.after are raw file content", async () => {
|
||||
//#given - a temp file
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts`
|
||||
const oldContent = "const x = 1\nconst y = 2\n"
|
||||
await Bun.write(tmpFile, oldContent)
|
||||
|
||||
const input = makeInput("write", "call-hashline-format")
|
||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
||||
|
||||
//#when - file is modified and after hook runs
|
||||
const newContent = "const x = 1\nconst y = 42\n"
|
||||
await Bun.write(tmpFile, newContent)
|
||||
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
//#then - before and after should be raw file content
|
||||
const filediff = afterOutput.metadata.filediff as { before: string; after: string }
|
||||
expect(filediff.before).toBe(oldContent)
|
||||
expect(filediff.after).toBe(newContent)
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TUI diff support (metadata.diff)", () => {
|
||||
test("injects unified diff string in metadata.diff for write tool TUI", async () => {
|
||||
//#given - a temp file
|
||||
const tmpDir = (await import("os")).tmpdir()
|
||||
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
|
||||
const oldContent = "line 1\nline 2\nline 3\n"
|
||||
await Bun.write(tmpFile, oldContent)
|
||||
|
||||
const input = makeInput("write", "call-tui-diff")
|
||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
||||
|
||||
//#when - file is modified
|
||||
const newContent = "line 1\nmodified line 2\nline 3\n"
|
||||
await Bun.write(tmpFile, newContent)
|
||||
|
||||
const afterOutput = makeAfterOutput()
|
||||
await hook["tool.execute.after"](input, afterOutput)
|
||||
|
||||
//#then - metadata.diff should be a unified diff string
|
||||
expect(afterOutput.metadata.diff).toBeDefined()
|
||||
expect(typeof afterOutput.metadata.diff).toBe("string")
|
||||
expect(afterOutput.metadata.diff).toContain("---")
|
||||
expect(afterOutput.metadata.diff).toContain("+++")
|
||||
expect(afterOutput.metadata.diff).toContain("@@")
|
||||
expect(afterOutput.metadata.diff).toContain("-line 2")
|
||||
expect(afterOutput.metadata.diff).toContain("+modified line 2")
|
||||
|
||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export { createHashlineEditDiffEnhancerHook } from "./hook"
|
||||
@@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig {
|
||||
hashline_edit?: { enabled: boolean }
|
||||
}
|
||||
|
||||
const READ_LINE_PATTERN = /^(\d+): ?(.*)$/
|
||||
const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/
|
||||
const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/
|
||||
const CONTENT_OPEN_TAG = "<content>"
|
||||
const CONTENT_CLOSE_TAG = "</content>"
|
||||
const FILE_OPEN_TAG = "<file>"
|
||||
const FILE_CLOSE_TAG = "</file>"
|
||||
|
||||
function isReadTool(toolName: string): boolean {
|
||||
return toolName.toLowerCase() === "read"
|
||||
@@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
|
||||
|
||||
function isTextFile(output: string): boolean {
|
||||
const firstLine = output.split("\n")[0] ?? ""
|
||||
return READ_LINE_PATTERN.test(firstLine)
|
||||
return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine)
|
||||
}
|
||||
|
||||
function parseReadLine(line: string): { lineNumber: number; content: string } | null {
|
||||
const colonMatch = COLON_READ_LINE_PATTERN.exec(line)
|
||||
if (colonMatch) {
|
||||
return {
|
||||
lineNumber: Number.parseInt(colonMatch[1], 10),
|
||||
content: colonMatch[2],
|
||||
}
|
||||
}
|
||||
|
||||
const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line)
|
||||
if (pipeMatch) {
|
||||
return {
|
||||
lineNumber: Number.parseInt(pipeMatch[1], 10),
|
||||
content: pipeMatch[2],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function transformLine(line: string): string {
|
||||
const match = READ_LINE_PATTERN.exec(line)
|
||||
if (!match) {
|
||||
const parsed = parseReadLine(line)
|
||||
if (!parsed) {
|
||||
return line
|
||||
}
|
||||
const lineNumber = parseInt(match[1], 10)
|
||||
const content = match[2]
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}#${hash}:${content}`
|
||||
const hash = computeLineHash(parsed.lineNumber, parsed.content)
|
||||
return `${parsed.lineNumber}#${hash}|${parsed.content}`
|
||||
}
|
||||
|
||||
function transformOutput(output: string): string {
|
||||
@@ -44,25 +65,43 @@ function transformOutput(output: string): string {
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
const contentStart = lines.indexOf(CONTENT_OPEN_TAG)
|
||||
const contentStart = lines.findIndex(
|
||||
(line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG)
|
||||
)
|
||||
const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG)
|
||||
const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG))
|
||||
const fileEnd = lines.indexOf(FILE_CLOSE_TAG)
|
||||
|
||||
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) {
|
||||
const fileLines = lines.slice(contentStart + 1, contentEnd)
|
||||
const blockStart = contentStart !== -1 ? contentStart : fileStart
|
||||
const blockEnd = contentStart !== -1 ? contentEnd : fileEnd
|
||||
const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG
|
||||
|
||||
if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) {
|
||||
const openLine = lines[blockStart] ?? ""
|
||||
const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag
|
||||
? openLine.slice(openTag.length)
|
||||
: null
|
||||
const fileLines = inlineFirst !== null
|
||||
? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)]
|
||||
: lines.slice(blockStart + 1, blockEnd)
|
||||
if (!isTextFile(fileLines[0] ?? "")) {
|
||||
return output
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
for (const line of fileLines) {
|
||||
if (!READ_LINE_PATTERN.test(line)) {
|
||||
if (!parseReadLine(line)) {
|
||||
result.push(...fileLines.slice(result.length))
|
||||
break
|
||||
}
|
||||
result.push(transformLine(line))
|
||||
}
|
||||
|
||||
return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n")
|
||||
const prefixLines = inlineFirst !== null
|
||||
? [...lines.slice(0, blockStart), openTag]
|
||||
: lines.slice(0, blockStart + 1)
|
||||
|
||||
return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n")
|
||||
}
|
||||
|
||||
if (!isTextFile(lines[0] ?? "")) {
|
||||
@@ -71,7 +110,7 @@ function transformOutput(output: string): string {
|
||||
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
if (!READ_LINE_PATTERN.test(line)) {
|
||||
if (!parseReadLine(line)) {
|
||||
result.push(...lines.slice(result.length))
|
||||
break
|
||||
}
|
||||
@@ -98,7 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined {
|
||||
}
|
||||
|
||||
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
|
||||
if (output.output.includes("Updated file (LINE#ID:content):")) {
|
||||
if (output.output.includes("Updated file (LINE#ID|content):")) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,7 +153,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
||||
|
||||
const content = await file.text()
|
||||
const hashlined = toHashlineContent(content)
|
||||
output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}`
|
||||
output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
|
||||
}
|
||||
|
||||
export function createHashlineReadEnhancerHook(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createHashlineReadEnhancerHook } from "./hook"
|
||||
@@ -45,11 +47,43 @@ describe("hashline-read-enhancer", () => {
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
|
||||
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
|
||||
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
|
||||
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
|
||||
expect(lines[10]).toBe("1: keep this unchanged")
|
||||
})
|
||||
|
||||
it("hashifies inline <content> format from updated OpenCode read tool", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "read", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "demo.ts",
|
||||
output: [
|
||||
"<path>/tmp/demo.ts</path>",
|
||||
"<type>file</type>",
|
||||
"<content>1: const x = 1",
|
||||
"2: const y = 2",
|
||||
"",
|
||||
"(End of file - total 2 lines)",
|
||||
"</content>",
|
||||
].join("\n"),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[0]).toBe("<path>/tmp/demo.ts</path>")
|
||||
expect(lines[1]).toBe("<type>file</type>")
|
||||
expect(lines[2]).toBe("<content>")
|
||||
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
|
||||
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
|
||||
expect(lines[6]).toBe("(End of file - total 2 lines)")
|
||||
expect(lines[7]).toBe("</content>")
|
||||
})
|
||||
|
||||
it("hashifies plain read output without content tags", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
@@ -71,12 +105,65 @@ describe("hashline-read-enhancer", () => {
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/)
|
||||
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/)
|
||||
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/)
|
||||
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/)
|
||||
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/)
|
||||
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/)
|
||||
expect(lines[4]).toBe("(End of file - total 3 lines)")
|
||||
})
|
||||
|
||||
it("hashifies read output with <file> and zero-padded pipe format", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "read", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "demo.ts",
|
||||
output: [
|
||||
"<file>",
|
||||
"00001| const x = 1",
|
||||
"00002| const y = 2",
|
||||
"",
|
||||
"(End of file - total 2 lines)",
|
||||
"</file>",
|
||||
].join("\n"),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
|
||||
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
|
||||
expect(lines[5]).toBe("</file>")
|
||||
})
|
||||
|
||||
it("hashifies pipe format even with leading spaces", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "read", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "demo.ts",
|
||||
output: [
|
||||
"<file>",
|
||||
" 00001| const x = 1",
|
||||
" 00002| const y = 2",
|
||||
"",
|
||||
"(End of file - total 2 lines)",
|
||||
"</file>",
|
||||
].join("\n"),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
|
||||
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
|
||||
})
|
||||
|
||||
it("appends LINE#ID output for write tool using metadata filepath", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
@@ -94,9 +181,9 @@ describe("hashline-read-enhancer", () => {
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("Updated file (LINE#ID:content):")
|
||||
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/)
|
||||
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/)
|
||||
expect(output.output).toContain("Updated file (LINE#ID|content):")
|
||||
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
|
||||
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
265
src/hooks/keyword-detector/ultrawork/gemini.ts
Normal file
265
src/hooks/keyword-detector/ultrawork/gemini.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Gemini-optimized ultrawork message.
|
||||
*
|
||||
* Key differences from default (Claude) variant:
|
||||
* - Mandatory intent gate enforcement before any action
|
||||
* - Anti-skip mechanism for Phase 0 intent classification
|
||||
* - Explicit self-check questions to counter Gemini's "eager" behavior
|
||||
* - Stronger scope constraints (Gemini's creativity causes scope creep)
|
||||
* - Anti-optimism checkpoints at verification stage
|
||||
*
|
||||
* Key differences from GPT variant:
|
||||
* - GPT naturally follows structured gates; Gemini needs explicit enforcement
|
||||
* - GPT self-delegates appropriately; Gemini tries to do everything itself
|
||||
* - GPT respects MUST NOT; Gemini treats constraints as suggestions
|
||||
*/
|
||||
|
||||
export const ULTRAWORK_GEMINI_MESSAGE = `<ultrawork-mode>
|
||||
|
||||
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
|
||||
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
<GEMINI_INTENT_GATE>
|
||||
## STEP 0: CLASSIFY INTENT — THIS IS NOT OPTIONAL
|
||||
|
||||
**Before ANY tool call, exploration, or action, you MUST output:**
|
||||
|
||||
\`\`\`
|
||||
I detect [TYPE] intent — [REASON].
|
||||
My approach: [ROUTING DECISION].
|
||||
\`\`\`
|
||||
|
||||
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
|
||||
|
||||
**SELF-CHECK (answer each before proceeding):**
|
||||
|
||||
1. Did the user EXPLICITLY ask me to build/create/implement something? → If NO, do NOT implement.
|
||||
2. Did the user say "look into", "check", "investigate", "explain"? → RESEARCH only. Do not code.
|
||||
3. Did the user ask "what do you think?" → EVALUATE and propose. Do NOT execute.
|
||||
4. Did the user report an error/bug? → MINIMAL FIX only. Do not refactor.
|
||||
|
||||
**YOUR FAILURE MODE: You see a request and immediately start coding. STOP. Classify first.**
|
||||
|
||||
| User Says | WRONG Response | CORRECT Response |
|
||||
| "explain how X works" | Start modifying X | Research → explain → STOP |
|
||||
| "look into this bug" | Fix it immediately | Investigate → report → WAIT |
|
||||
| "what about approach X?" | Implement approach X | Evaluate → propose → WAIT |
|
||||
| "improve the tests" | Rewrite everything | Assess first → propose → implement |
|
||||
|
||||
**IF YOU SKIPPED THIS SECTION: Your next tool call is INVALID. Go back and classify.**
|
||||
</GEMINI_INTENT_GATE>
|
||||
|
||||
## **ABSOLUTE CERTAINTY REQUIRED - DO NOT SKIP THIS**
|
||||
|
||||
**YOU MUST NOT START ANY IMPLEMENTATION UNTIL YOU ARE 100% CERTAIN.**
|
||||
|
||||
| **BEFORE YOU WRITE A SINGLE LINE OF CODE, YOU MUST:** |
|
||||
|-------------------------------------------------------|
|
||||
| **FULLY UNDERSTAND** what the user ACTUALLY wants (not what you ASSUME they want) |
|
||||
| **EXPLORE** the codebase to understand existing patterns, architecture, and context |
|
||||
| **HAVE A CRYSTAL CLEAR WORK PLAN** - if your plan is vague, YOUR WORK WILL FAIL |
|
||||
| **RESOLVE ALL AMBIGUITY** - if ANYTHING is unclear, ASK or INVESTIGATE |
|
||||
|
||||
### **MANDATORY CERTAINTY PROTOCOL**
|
||||
|
||||
**IF YOU ARE NOT 100% CERTAIN:**
|
||||
|
||||
1. **THINK DEEPLY** - What is the user's TRUE intent? What problem are they REALLY trying to solve?
|
||||
2. **EXPLORE THOROUGHLY** - Fire explore/librarian agents to gather ALL relevant context
|
||||
3. **CONSULT SPECIALISTS** - For hard/complex tasks, DO NOT struggle alone. Delegate:
|
||||
- **Oracle**: Conventional problems - architecture, debugging, complex logic
|
||||
- **Artistry**: Non-conventional problems - different approach needed, unusual constraints
|
||||
4. **ASK THE USER** - If ambiguity remains after exploration, ASK. Don't guess.
|
||||
|
||||
**SIGNS YOU ARE NOT READY TO IMPLEMENT:**
|
||||
- You're making assumptions about requirements
|
||||
- You're unsure which files to modify
|
||||
- You don't understand how existing code works
|
||||
- Your plan has "probably" or "maybe" in it
|
||||
- You can't explain the exact steps you'll take
|
||||
|
||||
**WHEN IN DOUBT:**
|
||||
\`\`\`
|
||||
task(subagent_type="explore", load_skills=[], prompt="I'm implementing [TASK DESCRIPTION] and need to understand [SPECIFIC KNOWLEDGE GAP]. Find [X] patterns in the codebase — show file paths, implementation approach, and conventions used. I'll use this to [HOW RESULTS WILL BE USED]. Focus on src/ directories, skip test files unless test patterns are specifically needed. Return concrete file paths with brief descriptions of what each file does.", run_in_background=true)
|
||||
task(subagent_type="librarian", load_skills=[], prompt="I'm working with [LIBRARY/TECHNOLOGY] and need [SPECIFIC INFORMATION]. Find official documentation and production-quality examples for [Y] — specifically: API reference, configuration options, recommended patterns, and common pitfalls. Skip beginner tutorials. I'll use this to [DECISION THIS WILL INFORM].", run_in_background=true)
|
||||
task(subagent_type="oracle", load_skills=[], prompt="I need architectural review of my approach to [TASK]. Here's my plan: [DESCRIBE PLAN WITH SPECIFIC FILES AND CHANGES]. My concerns are: [LIST SPECIFIC UNCERTAINTIES]. Please evaluate: correctness of approach, potential issues I'm missing, and whether a better alternative exists.", run_in_background=false)
|
||||
\`\`\`
|
||||
|
||||
**ONLY AFTER YOU HAVE:**
|
||||
- Gathered sufficient context via agents
|
||||
- Resolved all ambiguities
|
||||
- Created a precise, step-by-step work plan
|
||||
- Achieved 100% confidence in your understanding
|
||||
|
||||
**...THEN AND ONLY THEN MAY YOU BEGIN IMPLEMENTATION.**
|
||||
|
||||
---
|
||||
|
||||
## **NO EXCUSES. NO COMPROMISES. DELIVER WHAT WAS ASKED.**
|
||||
|
||||
**THE USER'S ORIGINAL REQUEST IS SACRED. YOU MUST FULFILL IT EXACTLY.**
|
||||
|
||||
| VIOLATION | CONSEQUENCE |
|
||||
|-----------|-------------|
|
||||
| "I couldn't because..." | **UNACCEPTABLE.** Find a way or ask for help. |
|
||||
| "This is a simplified version..." | **UNACCEPTABLE.** Deliver the FULL implementation. |
|
||||
| "You can extend this later..." | **UNACCEPTABLE.** Finish it NOW. |
|
||||
| "Due to limitations..." | **UNACCEPTABLE.** Use agents, tools, whatever it takes. |
|
||||
| "I made some assumptions..." | **UNACCEPTABLE.** You should have asked FIRST. |
|
||||
|
||||
**THERE ARE NO VALID EXCUSES FOR:**
|
||||
- Delivering partial work
|
||||
- Changing scope without explicit user approval
|
||||
- Making unauthorized simplifications
|
||||
- Stopping before the task is 100% complete
|
||||
- Compromising on any stated requirement
|
||||
|
||||
**IF YOU ENCOUNTER A BLOCKER:**
|
||||
1. **DO NOT** give up
|
||||
2. **DO NOT** deliver a compromised version
|
||||
3. **DO** consult specialists (oracle for conventional, artistry for non-conventional)
|
||||
4. **DO** ask the user for guidance
|
||||
5. **DO** explore alternative approaches
|
||||
|
||||
**THE USER ASKED FOR X. DELIVER EXACTLY X. PERIOD.**
|
||||
|
||||
---
|
||||
|
||||
<TOOL_CALL_MANDATE>
|
||||
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
|
||||
|
||||
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
|
||||
|
||||
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT.
|
||||
|
||||
**RULES (VIOLATION = BROKEN RESPONSE):**
|
||||
1. **NEVER answer about code without reading files first.** Read them AGAIN.
|
||||
2. **NEVER claim done without \`lsp_diagnostics\`.** Your confidence is wrong more often than right.
|
||||
3. **NEVER skip delegation.** Specialists produce better results. USE THEM.
|
||||
4. **NEVER reason about what a file "probably contains."** READ IT.
|
||||
5. **NEVER produce ZERO tool calls when action was requested.** Thinking is not doing.
|
||||
</TOOL_CALL_MANDATE>
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## MANDATORY: PLAN AGENT INVOCATION (NON-NEGOTIABLE)
|
||||
|
||||
**YOU MUST ALWAYS INVOKE THE PLAN AGENT FOR ANY NON-TRIVIAL TASK.**
|
||||
|
||||
| Condition | Action |
|
||||
|-----------|--------|
|
||||
| Task has 2+ steps | MUST call plan agent |
|
||||
| Task scope unclear | MUST call plan agent |
|
||||
| Implementation required | MUST call plan agent |
|
||||
| Architecture decision needed | MUST call plan agent |
|
||||
|
||||
\`\`\`
|
||||
task(subagent_type="plan", load_skills=[], prompt="<gathered context + user request>")
|
||||
\`\`\`
|
||||
|
||||
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
|
||||
|
||||
**Plan agent returns a session_id. USE IT for follow-up interactions.**
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], prompt="<your answer>")\` |
|
||||
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Please adjust: <feedback>")\` |
|
||||
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Add more detail to Task N")\` |
|
||||
|
||||
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
|
||||
|
||||
---
|
||||
|
||||
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
|
||||
|
||||
**You have a strong tendency to do work yourself. RESIST THIS.**
|
||||
|
||||
**DEFAULT BEHAVIOR: DELEGATE. DO NOT WORK YOURSELF.**
|
||||
|
||||
| Task Type | Action | Why |
|
||||
|-----------|--------|-----|
|
||||
| Codebase exploration | task(subagent_type="explore", load_skills=[], run_in_background=true) | Parallel, context-efficient |
|
||||
| Documentation lookup | task(subagent_type="librarian", load_skills=[], run_in_background=true) | Specialized knowledge |
|
||||
| Planning | task(subagent_type="plan", load_skills=[]) | Parallel task graph + structured TODO list |
|
||||
| Hard problem (conventional) | task(subagent_type="oracle", load_skills=[]) | Architecture, debugging, complex logic |
|
||||
| Hard problem (non-conventional) | task(category="artistry", load_skills=[...]) | Different approach needed |
|
||||
| Implementation | task(category="...", load_skills=[...]) | Domain-optimized models |
|
||||
|
||||
**YOU SHOULD ONLY DO IT YOURSELF WHEN:**
|
||||
- Task is trivially simple (1-2 lines, obvious change)
|
||||
- You have ALL context already loaded
|
||||
- Delegation overhead exceeds task complexity
|
||||
|
||||
**OTHERWISE: DELEGATE. ALWAYS.**
|
||||
|
||||
---
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via task(run_in_background=true) - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. **CLASSIFY INTENT** (MANDATORY — see GEMINI_INTENT_GATE above)
|
||||
2. Spawn exploration/librarian agents via task(run_in_background=true) in PARALLEL
|
||||
3. Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
|
||||
|
||||
**NOTHING is "done" without PROOF it works.**
|
||||
|
||||
**YOUR SELF-ASSESSMENT IS UNRELIABLE.** What feels like 95% confidence = ~60% actual correctness.
|
||||
|
||||
| Phase | Action | Required Evidence |
|
||||
|-------|--------|-------------------|
|
||||
| **Build** | Run build command | Exit code 0, no errors |
|
||||
| **Test** | Execute test suite | All tests pass (screenshot/output) |
|
||||
| **Lint** | Run lsp_diagnostics | Zero new errors on changed files |
|
||||
| **Manual Verify** | Test the actual feature | Describe what you observed |
|
||||
| **Regression** | Ensure nothing broke | Existing tests still pass |
|
||||
|
||||
<ANTI_OPTIMISM_CHECKPOINT>
|
||||
## BEFORE YOU CLAIM DONE, ANSWER HONESTLY:
|
||||
|
||||
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
|
||||
2. Did I run the tests and see them PASS? (not "they should pass")
|
||||
3. Did I read the actual output of every command? (not skim)
|
||||
4. Is EVERY requirement from the request actually implemented? (re-read the request NOW)
|
||||
5. Did I classify intent at the start? (if not, my entire approach may be wrong)
|
||||
|
||||
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
|
||||
</ANTI_OPTIMISM_CHECKPOINT>
|
||||
|
||||
**WITHOUT evidence = NOT verified = NOT done.**
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
1. CLASSIFY INTENT (MANDATORY)
|
||||
2. EXPLORES + LIBRARIANS
|
||||
3. GATHER -> PLAN AGENT SPAWN
|
||||
4. WORK BY DELEGATING TO ANOTHER AGENTS
|
||||
|
||||
NOW.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
export function getGeminiUltraworkMessage(): string {
|
||||
return ULTRAWORK_GEMINI_MESSAGE
|
||||
}
|
||||
@@ -4,19 +4,22 @@
|
||||
* Routing:
|
||||
* 1. Planner agents (prometheus, plan) → planner.ts
|
||||
* 2. GPT 5.2 models → gpt5.2.ts
|
||||
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||
* 3. Gemini models → gemini.ts
|
||||
* 4. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||
*/
|
||||
|
||||
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector"
|
||||
export { isPlannerAgent, isGptModel, isGeminiModel, getUltraworkSource } from "./source-detector"
|
||||
export type { UltraworkSource } from "./source-detector"
|
||||
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
|
||||
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
|
||||
export { ULTRAWORK_GEMINI_MESSAGE, getGeminiUltraworkMessage } from "./gemini"
|
||||
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
|
||||
|
||||
import { getUltraworkSource } from "./source-detector"
|
||||
import { getPlannerUltraworkMessage } from "./planner"
|
||||
import { getGptUltraworkMessage } from "./gpt5.2"
|
||||
import { getDefaultUltraworkMessage } from "./default"
|
||||
import { getGeminiUltraworkMessage } from "./gemini"
|
||||
|
||||
/**
|
||||
* Gets the appropriate ultrawork message based on agent and model context.
|
||||
@@ -29,6 +32,8 @@ export function getUltraworkMessage(agentName?: string, modelID?: string): strin
|
||||
return getPlannerUltraworkMessage()
|
||||
case "gpt":
|
||||
return getGptUltraworkMessage()
|
||||
case "gemini":
|
||||
return getGeminiUltraworkMessage()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultUltraworkMessage()
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* Routing logic:
|
||||
* 1. Planner agents (prometheus, plan) → planner.ts
|
||||
* 2. GPT 5.2 models → gpt5.2.ts
|
||||
* 3. Everything else (Claude, etc.) → default.ts
|
||||
* 3. Gemini models → gemini.ts
|
||||
* 4. Everything else (Claude, etc.) → default.ts
|
||||
*/
|
||||
|
||||
import { isGptModel } from "../../../agents/types"
|
||||
import { isGptModel, isGeminiModel } from "../../../agents/types"
|
||||
|
||||
/**
|
||||
* Checks if agent is a planner-type agent.
|
||||
@@ -22,10 +23,10 @@ export function isPlannerAgent(agentName?: string): boolean {
|
||||
return /\bplan\b/.test(normalized)
|
||||
}
|
||||
|
||||
export { isGptModel }
|
||||
export { isGptModel, isGeminiModel }
|
||||
|
||||
/** Ultrawork message source type */
|
||||
export type UltraworkSource = "planner" | "gpt" | "default"
|
||||
export type UltraworkSource = "planner" | "gpt" | "gemini" | "default"
|
||||
|
||||
/**
|
||||
* Determines which ultrawork message source to use.
|
||||
@@ -44,6 +45,11 @@ export function getUltraworkSource(
|
||||
return "gpt"
|
||||
}
|
||||
|
||||
|
||||
// Priority 3: Gemini models
|
||||
if (modelID && isGeminiModel(modelID)) {
|
||||
return "gemini"
|
||||
}
|
||||
// Default: Claude and other models
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ describe("preemptive-compaction", () => {
|
||||
|
||||
it("should use 1M limit when model cache flag is enabled", async () => {
|
||||
//#given
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
|
||||
anthropicContext1MEnabled: true,
|
||||
})
|
||||
const sessionID = "ses_1m_flag"
|
||||
@@ -308,7 +308,7 @@ describe("preemptive-compaction", () => {
|
||||
it("should keep env var fallback when model cache flag is disabled", async () => {
|
||||
//#given
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
const sessionID = "ses_env_fallback"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { log } from "../shared/logger"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
@@ -51,6 +53,7 @@ type PluginInput = {
|
||||
|
||||
export function createPreemptiveCompactionHook(
|
||||
ctx: PluginInput,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
) {
|
||||
const compactionInProgress = new Set<string>()
|
||||
@@ -84,9 +87,16 @@ export function createPreemptiveCompactionHook(
|
||||
compactionInProgress.add(sessionID)
|
||||
|
||||
try {
|
||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||
pluginConfig,
|
||||
sessionID,
|
||||
cached.providerID,
|
||||
modelID
|
||||
)
|
||||
|
||||
await ctx.client.session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: cached.providerID, modelID, auto: true } as never,
|
||||
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
|
||||
93
src/hooks/session-notification-input-needed.test.ts
Normal file
93
src/hooks/session-notification-input-needed.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
|
||||
const { createSessionNotification } = require("./session-notification")
|
||||
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
|
||||
const utils = require("./session-notification-utils")
|
||||
|
||||
describe("session-notification input-needed events", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
notificationCalls = []
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subagentSessions.clear()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("sends question notification when question tool asks for input", async () => {
|
||||
const sessionID = "main-question"
|
||||
setMainSession(sessionID)
|
||||
const hook = createSessionNotification(createMockPluginInput())
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: {
|
||||
sessionID,
|
||||
tool: "question",
|
||||
args: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which branch should we use?",
|
||||
options: [{ label: "main" }, { label: "dev" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(notificationCalls).toHaveLength(1)
|
||||
expect(notificationCalls[0]).toContain("Agent is asking a question")
|
||||
})
|
||||
|
||||
test("sends permission notification for permission events", async () => {
|
||||
const sessionID = "main-permission"
|
||||
setMainSession(sessionID)
|
||||
const hook = createSessionNotification(createMockPluginInput())
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
sessionID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(notificationCalls).toHaveLength(1)
|
||||
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -15,6 +15,8 @@ import { createIdleNotificationScheduler } from "./session-notification-schedule
|
||||
interface SessionNotificationConfig {
|
||||
title?: string
|
||||
message?: string
|
||||
questionMessage?: string
|
||||
permissionMessage?: string
|
||||
playSound?: boolean
|
||||
soundPath?: string
|
||||
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
|
||||
@@ -36,6 +38,8 @@ export function createSessionNotification(
|
||||
const mergedConfig = {
|
||||
title: "OpenCode",
|
||||
message: "Agent is ready for input",
|
||||
questionMessage: "Agent is asking a question",
|
||||
permissionMessage: "Agent needs permission to continue",
|
||||
playSound: false,
|
||||
soundPath: defaultSoundPath,
|
||||
idleConfirmationDelay: 1500,
|
||||
@@ -53,6 +57,56 @@ export function createSessionNotification(
|
||||
playSound: playSessionNotificationSound,
|
||||
})
|
||||
|
||||
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
|
||||
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
||||
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
|
||||
|
||||
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
|
||||
const sessionID = properties?.sessionID
|
||||
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
|
||||
|
||||
const sessionId = properties?.sessionId
|
||||
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
|
||||
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const infoSessionID = info?.sessionID
|
||||
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
|
||||
|
||||
const infoSessionId = info?.sessionId
|
||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shouldNotifyForSession = (sessionID: string): boolean => {
|
||||
if (subagentSessions.has(sessionID)) return false
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
|
||||
const tool = properties?.tool
|
||||
if (typeof tool === "string" && tool.length > 0) return tool
|
||||
|
||||
const name = properties?.name
|
||||
if (typeof name === "string" && name.length > 0) return name
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
|
||||
const args = properties?.args as Record<string, unknown> | undefined
|
||||
const questions = args?.questions
|
||||
if (!Array.isArray(questions) || questions.length === 0) return ""
|
||||
|
||||
const firstQuestion = questions[0] as Record<string, unknown> | undefined
|
||||
const questionText = firstQuestion?.question
|
||||
return typeof questionText === "string" ? questionText : ""
|
||||
}
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (currentPlatform === "unsupported") return
|
||||
|
||||
@@ -68,14 +122,10 @@ export function createSessionNotification(
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = getSessionID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (subagentSessions.has(sessionID)) return
|
||||
|
||||
// Only trigger notifications for the main session (not subagent sessions)
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) return
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
scheduler.scheduleIdleNotification(sessionID)
|
||||
return
|
||||
@@ -83,17 +133,47 @@ export function createSessionNotification(
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = getSessionID({ ...props, info })
|
||||
if (sessionID) {
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (PERMISSION_EVENTS.has(event.type)) {
|
||||
const sessionID = getSessionID(props)
|
||||
if (!sessionID) return
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
|
||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = getSessionID(props)
|
||||
if (sessionID) {
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
|
||||
if (event.type === "tool.execute.before") {
|
||||
const toolName = getEventToolName(props)?.toLowerCase()
|
||||
if (toolName && QUESTION_TOOLS.has(toolName)) {
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
const questionText = getQuestionText(props)
|
||||
const message = PERMISSION_HINT_PATTERN.test(questionText)
|
||||
? mergedConfig.permissionMessage
|
||||
: mergedConfig.questionMessage
|
||||
|
||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
|
||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { extractMessageIndex } from "./detect-error-type"
|
||||
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
|
||||
import {
|
||||
findEmptyMessageByIndex,
|
||||
findEmptyMessages,
|
||||
findMessagesWithEmptyTextParts,
|
||||
findMessagesWithThinkingOnly,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "./storage"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
|
||||
import { injectTextPartAsync } from "./storage/text-part-injector"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||
|
||||
export async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
if (isSqliteBackend()) {
|
||||
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
|
||||
placeholderText: PLACEHOLDER_TEXT,
|
||||
replaceEmptyTextPartsAsync,
|
||||
injectTextPartAsync,
|
||||
findMessagesWithEmptyTextPartsFromSDK,
|
||||
})
|
||||
}
|
||||
|
||||
const targetIndex = extractMessageIndex(error)
|
||||
const failedID = failedAssistantMsg.info?.id
|
||||
let anySuccess = false
|
||||
|
||||
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
|
||||
for (const messageID of messagesWithEmptyText) {
|
||||
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
|
||||
for (const messageID of thinkingOnlyIDs) {
|
||||
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex !== null) {
|
||||
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
|
||||
if (targetMessageID) {
|
||||
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
|
||||
return true
|
||||
}
|
||||
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedID) {
|
||||
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
|
||||
return true
|
||||
}
|
||||
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
for (const messageID of emptyMessageIDs) {
|
||||
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
|
||||
anySuccess = true
|
||||
}
|
||||
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
type ClientWithPromptAsync = {
|
||||
session: {
|
||||
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
@@ -77,8 +83,7 @@ export async function recoverToolResultMissing(
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
await client.session.promptAsync(promptInput)
|
||||
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
|
||||
34
src/hooks/shared/compaction-model-resolver.ts
Normal file
34
src/hooks/shared/compaction-model-resolver.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
|
||||
export function resolveCompactionModel(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
sessionID: string,
|
||||
originalProviderID: string,
|
||||
originalModelID: string
|
||||
): { providerID: string; modelID: string } {
|
||||
const sessionAgentName = getSessionAgent(sessionID)
|
||||
|
||||
if (!sessionAgentName || !pluginConfig.agents) {
|
||||
return { providerID: originalProviderID, modelID: originalModelID }
|
||||
}
|
||||
|
||||
const agentConfigKey = getAgentConfigKey(sessionAgentName)
|
||||
const agentConfig = (pluginConfig.agents as Record<string, { compaction?: { model?: string } } | undefined>)[agentConfigKey]
|
||||
const compactionConfig = agentConfig?.compaction
|
||||
|
||||
if (!compactionConfig?.model) {
|
||||
return { providerID: originalProviderID, modelID: originalModelID }
|
||||
}
|
||||
|
||||
const modelParts = compactionConfig.model.split("/")
|
||||
if (modelParts.length < 2) {
|
||||
return { providerID: originalProviderID, modelID: originalModelID }
|
||||
}
|
||||
|
||||
return {
|
||||
providerID: modelParts[0],
|
||||
modelID: modelParts.slice(1).join("/"),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,18 @@ interface MessageWithParts {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
thinking?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface MessageInfoExtended {
|
||||
id: string
|
||||
role: string
|
||||
sessionID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
@@ -91,7 +103,7 @@ function findPreviousThinkingContent(
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as any).thinking || (part as any).text
|
||||
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
@@ -114,7 +126,7 @@ function prependThinkingBlock(message: MessageWithParts, thinkingContent: string
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as any).sessionID || "",
|
||||
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
@@ -138,7 +150,7 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||
const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
import { log } from "../../shared"
|
||||
|
||||
@@ -14,7 +14,7 @@ type GuardArgs = {
|
||||
|
||||
const MAX_TRACKED_SESSIONS = 256
|
||||
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
||||
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -37,6 +37,8 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
|
||||
|
||||
function toCanonicalPath(absolutePath: string): string {
|
||||
let canonicalPath = absolutePath
|
||||
|
||||
@@ -73,7 +75,6 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
const readPermissionsBySession = new Map<string, Set<string>>()
|
||||
const sessionLastAccess = new Map<string, number>()
|
||||
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
|
||||
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
|
||||
|
||||
const touchSession = (sessionID: string): void => {
|
||||
sessionLastAccess.set(sessionID, Date.now())
|
||||
@@ -174,16 +175,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
|
||||
|
||||
if (!isInsideSessionDirectory) {
|
||||
if (toolName === "read") {
|
||||
return
|
||||
}
|
||||
|
||||
log("[write-existing-file-guard] Blocking write outside session directory", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
resolvedPath,
|
||||
})
|
||||
throw new Error(OUTSIDE_SESSION_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName === "read") {
|
||||
@@ -206,7 +198,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
return
|
||||
}
|
||||
|
||||
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
|
||||
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
|
||||
if (isSisyphusPath) {
|
||||
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
|
||||
import { createWriteExistingFileGuardHook } from "./index"
|
||||
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
|
||||
|
||||
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
|
||||
|
||||
@@ -339,7 +338,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("#given existing file outside session directory #when write executes #then blocks", async () => {
|
||||
test("#given existing file outside session directory #when write executes #then allows", async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
|
||||
|
||||
try {
|
||||
@@ -349,9 +348,9 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
await expect(
|
||||
invoke({
|
||||
tool: "write",
|
||||
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
|
||||
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
|
||||
})
|
||||
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
|
||||
).resolves.toBeDefined()
|
||||
} finally {
|
||||
rmSync(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1161,8 +1161,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
||||
getAgentDisplayName("sisyphus"),
|
||||
getAgentDisplayName("hephaestus"),
|
||||
getAgentDisplayName("atlas"),
|
||||
])
|
||||
const AGENTS_WITHOUT_TODO_DENY = new Set([
|
||||
getAgentDisplayName("prometheus"),
|
||||
getAgentDisplayName("sisyphus-junior"),
|
||||
])
|
||||
@@ -1206,10 +1204,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
||||
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
|
||||
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
|
||||
}
|
||||
for (const agentName of AGENTS_WITHOUT_TODO_DENY) {
|
||||
expect(agentResult[agentName]?.permission?.todowrite).toBeUndefined()
|
||||
expect(agentResult[agentName]?.permission?.todoread).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not deny todowrite/todoread when task_system is disabled", async () => {
|
||||
|
||||
83
src/plugin-handlers/tool-config-handler.test.ts
Normal file
83
src/plugin-handlers/tool-config-handler.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { applyToolConfig } from "./tool-config-handler"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
function createParams(overrides: {
|
||||
taskSystem?: boolean
|
||||
agents?: string[]
|
||||
}) {
|
||||
const agentResult: Record<string, { permission?: Record<string, unknown> }> = {}
|
||||
for (const agent of overrides.agents ?? []) {
|
||||
agentResult[agent] = { permission: {} }
|
||||
}
|
||||
|
||||
return {
|
||||
config: { tools: {}, permission: {} } as Record<string, unknown>,
|
||||
pluginConfig: {
|
||||
experimental: { task_system: overrides.taskSystem ?? false },
|
||||
} as OhMyOpenCodeConfig,
|
||||
agentResult: agentResult as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
describe("applyToolConfig", () => {
|
||||
describe("#given task_system is enabled", () => {
|
||||
describe("#when applying tool config", () => {
|
||||
it("#then should deny todowrite and todoread globally", () => {
|
||||
const params = createParams({ taskSystem: true })
|
||||
|
||||
applyToolConfig(params)
|
||||
|
||||
const tools = params.config.tools as Record<string, unknown>
|
||||
expect(tools.todowrite).toBe(false)
|
||||
expect(tools.todoread).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"atlas",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
"sisyphus-junior",
|
||||
])("#then should deny todo tools for %s agent", (agentName) => {
|
||||
const params = createParams({
|
||||
taskSystem: true,
|
||||
agents: [agentName],
|
||||
})
|
||||
|
||||
applyToolConfig(params)
|
||||
|
||||
const agent = params.agentResult[agentName] as {
|
||||
permission: Record<string, unknown>
|
||||
}
|
||||
expect(agent.permission.todowrite).toBe("deny")
|
||||
expect(agent.permission.todoread).toBe("deny")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task_system is disabled", () => {
|
||||
describe("#when applying tool config", () => {
|
||||
it.each([
|
||||
"atlas",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
"sisyphus-junior",
|
||||
])("#then should NOT deny todo tools for %s agent", (agentName) => {
|
||||
const params = createParams({
|
||||
taskSystem: false,
|
||||
agents: [agentName],
|
||||
})
|
||||
|
||||
applyToolConfig(params)
|
||||
|
||||
const agent = params.agentResult[agentName] as {
|
||||
permission: Record<string, unknown>
|
||||
}
|
||||
expect(agent.permission.todowrite).toBeUndefined()
|
||||
expect(agent.permission.todoread).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,7 @@ export function applyToolConfig(params: {
|
||||
question: questionPermission,
|
||||
"task_*": "allow",
|
||||
teammate: "allow",
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
const junior = agentByKey(params.agentResult, "sisyphus-junior");
|
||||
@@ -93,6 +94,7 @@ export function applyToolConfig(params: {
|
||||
task: "allow",
|
||||
"task_*": "allow",
|
||||
teammate: "allow",
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ describe("createEventHandler - model fallback", () => {
|
||||
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_message_updated_fallback"
|
||||
const { handler, abortCalls, promptCalls } = createHandler()
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
@@ -95,7 +96,8 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#given
|
||||
const sessionID = "ses_main_fallback_nested"
|
||||
setMainSession(sessionID)
|
||||
const { handler, abortCalls, promptCalls } = createHandler()
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
@@ -340,4 +342,64 @@ describe("createEventHandler - model fallback", () => {
|
||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test("does not trigger model-fallback retry when modelFallback hook is not provided (disabled by default)", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_disabled_by_default"
|
||||
setMainSession(sessionID)
|
||||
const { handler, abortCalls, promptCalls } = createHandler()
|
||||
|
||||
//#when - message.updated with assistant error
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_err_disabled_1",
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1, completed: 2 },
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message:
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
|
||||
isRetryable: true,
|
||||
},
|
||||
},
|
||||
parentID: "msg_user_disabled_1",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
providerID: "anthropic",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when - session.error with retryable error
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
error: {
|
||||
message:
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - no abort or prompt calls should have been made
|
||||
expect(abortCalls).toEqual([])
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,58 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import type { PluginContext } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import type { PluginContext } from "./types";
|
||||
|
||||
import {
|
||||
clearSessionAgent,
|
||||
getMainSessionID,
|
||||
getSessionAgent,
|
||||
setMainSession,
|
||||
subagentSessions,
|
||||
syncSubagentSessions,
|
||||
setMainSession,
|
||||
updateSessionAgent,
|
||||
} from "../features/claude-code-session-state"
|
||||
import { resetMessageCursor } from "../shared"
|
||||
import { lspManager } from "../tools"
|
||||
import { shouldRetryError } from "../shared/model-error-classifier"
|
||||
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||
import { log } from "../shared/logger"
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||
} from "../features/claude-code-session-state";
|
||||
import {
|
||||
clearPendingModelFallback,
|
||||
clearSessionFallbackChain,
|
||||
setPendingModelFallback,
|
||||
} from "../hooks/model-fallback/hook";
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks"
|
||||
import type { Managers } from "../create-managers"
|
||||
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
|
||||
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"
|
||||
import type { CreatedHooks } from "../create-hooks";
|
||||
import type { Managers } from "../create-managers";
|
||||
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
|
||||
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
|
||||
|
||||
type FirstMessageVariantGate = {
|
||||
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
|
||||
clear: (sessionID: string) => void
|
||||
}
|
||||
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
|
||||
clear: (sessionID: string) => void;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function normalizeFallbackModelID(modelID: string): string {
|
||||
return modelID
|
||||
.replace(/-thinking$/i, "")
|
||||
.replace(/-max$/i, "")
|
||||
.replace(/-high$/i, "")
|
||||
.replace(/-high$/i, "");
|
||||
}
|
||||
|
||||
function extractErrorName(error: unknown): string | undefined {
|
||||
if (isRecord(error) && typeof error.name === "string") return error.name
|
||||
if (error instanceof Error) return error.name
|
||||
return undefined
|
||||
if (isRecord(error) && typeof error.name === "string") return error.name;
|
||||
if (error instanceof Error) return error.name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
if (isRecord(error)) {
|
||||
const candidates: unknown[] = [
|
||||
@@ -56,116 +61,115 @@ function extractErrorMessage(error: unknown): string {
|
||||
error.error,
|
||||
isRecord(error.data) ? error.data.error : undefined,
|
||||
error.cause,
|
||||
]
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
|
||||
return candidate.message
|
||||
return candidate.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error)
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function extractProviderModelFromErrorMessage(
|
||||
message: string,
|
||||
): { providerID?: string; modelID?: string } {
|
||||
const lower = message.toLowerCase()
|
||||
function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
|
||||
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i);
|
||||
if (providerModel) {
|
||||
return {
|
||||
providerID: providerModel[1],
|
||||
modelID: providerModel[2],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
|
||||
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i);
|
||||
if (modelOnly) {
|
||||
return {
|
||||
modelID: modelOnly[1],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
type EventInput = Parameters<
|
||||
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
|
||||
>[0]
|
||||
type EventInput = Parameters<NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>>[0];
|
||||
export function createEventHandler(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
firstMessageVariantGate: FirstMessageVariantGate
|
||||
managers: Managers
|
||||
hooks: CreatedHooks
|
||||
ctx: PluginContext;
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
firstMessageVariantGate: FirstMessageVariantGate;
|
||||
managers: Managers;
|
||||
hooks: CreatedHooks;
|
||||
}): (input: EventInput) => Promise<void> {
|
||||
const { ctx, firstMessageVariantGate, managers, hooks } = args
|
||||
const { ctx, firstMessageVariantGate, managers, hooks } = args;
|
||||
const pluginContext = ctx as {
|
||||
directory: string
|
||||
directory: string;
|
||||
client: {
|
||||
session: {
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>;
|
||||
prompt: (input: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: "text"; text: string }> }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: "text"; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const isRuntimeFallbackEnabled =
|
||||
hooks.runtimeFallback !== null &&
|
||||
hooks.runtimeFallback !== undefined &&
|
||||
(typeof args.pluginConfig.runtime_fallback === "boolean"
|
||||
? args.pluginConfig.runtime_fallback
|
||||
: (args.pluginConfig.runtime_fallback?.enabled ?? false))
|
||||
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
|
||||
|
||||
const isModelFallbackEnabled =
|
||||
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
||||
|
||||
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||
const lastHandledModelErrorMessageID = new Map<string, string>()
|
||||
const lastHandledRetryStatusKey = new Map<string, string>()
|
||||
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
|
||||
const lastHandledModelErrorMessageID = new Map<string, string>();
|
||||
const lastHandledRetryStatusKey = new Map<string, string>();
|
||||
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
|
||||
|
||||
const dispatchToHooks = async (input: EventInput): Promise<void> => {
|
||||
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
|
||||
await Promise.resolve(hooks.sessionNotification?.(input))
|
||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
|
||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
|
||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
|
||||
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.rulesInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.thinkMode?.event?.(input))
|
||||
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
|
||||
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
|
||||
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
|
||||
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
|
||||
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
|
||||
await Promise.resolve(hooks.ralphLoop?.event?.(input))
|
||||
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
|
||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
|
||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
|
||||
await Promise.resolve(hooks.atlasHook?.handler?.(input))
|
||||
}
|
||||
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||
await Promise.resolve(hooks.sessionNotification?.(input));
|
||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
||||
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.rulesInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.thinkMode?.event?.(input));
|
||||
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
|
||||
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
|
||||
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
|
||||
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
|
||||
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
|
||||
await Promise.resolve(hooks.ralphLoop?.event?.(input));
|
||||
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
|
||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
|
||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
|
||||
await Promise.resolve(hooks.atlasHook?.handler?.(input));
|
||||
};
|
||||
|
||||
const recentSyntheticIdles = new Map<string, number>()
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
const DEDUP_WINDOW_MS = 500
|
||||
const recentSyntheticIdles = new Map<string, number>();
|
||||
const recentRealIdles = new Map<string, number>();
|
||||
const DEDUP_WINDOW_MS = 500;
|
||||
|
||||
const shouldAutoRetrySession = (sessionID: string): boolean => {
|
||||
if (syncSubagentSessions.has(sessionID)) return true
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID) return sessionID === mainSessionID
|
||||
if (syncSubagentSessions.has(sessionID)) return true;
|
||||
const mainSessionID = getMainSessionID();
|
||||
if (mainSessionID) return sessionID === mainSessionID;
|
||||
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
|
||||
// In that case, treat any non-subagent session as the "main" interactive session.
|
||||
return !subagentSessions.has(sessionID)
|
||||
}
|
||||
return !subagentSessions.has(sessionID);
|
||||
};
|
||||
|
||||
return async (input): Promise<void> => {
|
||||
pruneRecentSyntheticIdles({
|
||||
@@ -173,230 +177,227 @@ export function createEventHandler(args: {
|
||||
recentRealIdles,
|
||||
now: Date.now(),
|
||||
dedupWindowMs: DEDUP_WINDOW_MS,
|
||||
})
|
||||
});
|
||||
|
||||
if (input.event.type === "session.idle") {
|
||||
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
|
||||
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
|
||||
| string
|
||||
| undefined;
|
||||
if (sessionID) {
|
||||
const emittedAt = recentSyntheticIdles.get(sessionID)
|
||||
const emittedAt = recentSyntheticIdles.get(sessionID);
|
||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentSyntheticIdles.delete(sessionID)
|
||||
return
|
||||
recentSyntheticIdles.delete(sessionID);
|
||||
return;
|
||||
}
|
||||
recentRealIdles.set(sessionID, Date.now())
|
||||
recentRealIdles.set(sessionID, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
await dispatchToHooks(input)
|
||||
await dispatchToHooks(input);
|
||||
|
||||
const syntheticIdle = normalizeSessionStatusToIdle(input)
|
||||
const syntheticIdle = normalizeSessionStatusToIdle(input);
|
||||
if (syntheticIdle) {
|
||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
|
||||
const emittedAt = recentRealIdles.get(sessionID)
|
||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
|
||||
const emittedAt = recentRealIdles.get(sessionID);
|
||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentRealIdles.delete(sessionID)
|
||||
return
|
||||
recentRealIdles.delete(sessionID);
|
||||
return;
|
||||
}
|
||||
recentSyntheticIdles.set(sessionID, Date.now())
|
||||
await dispatchToHooks(syntheticIdle as EventInput)
|
||||
recentSyntheticIdles.set(sessionID, Date.now());
|
||||
await dispatchToHooks(syntheticIdle as EventInput);
|
||||
}
|
||||
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
|
||||
|
||||
if (!sessionInfo?.parentID) {
|
||||
setMainSession(sessionInfo?.id)
|
||||
setMainSession(sessionInfo?.id);
|
||||
}
|
||||
|
||||
firstMessageVariantGate.markSessionCreated(sessionInfo)
|
||||
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
||||
|
||||
await managers.tmuxSessionManager.onSessionCreated(
|
||||
event as {
|
||||
type: string
|
||||
type: string;
|
||||
properties?: {
|
||||
info?: { id?: string; parentID?: string; title?: string }
|
||||
}
|
||||
info?: { id?: string; parentID?: string; title?: string };
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined)
|
||||
setMainSession(undefined);
|
||||
}
|
||||
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionAgent(sessionInfo.id)
|
||||
lastHandledModelErrorMessageID.delete(sessionInfo.id)
|
||||
lastHandledRetryStatusKey.delete(sessionInfo.id)
|
||||
lastKnownModelBySession.delete(sessionInfo.id)
|
||||
clearPendingModelFallback(sessionInfo.id)
|
||||
clearSessionFallbackChain(sessionInfo.id)
|
||||
resetMessageCursor(sessionInfo.id)
|
||||
firstMessageVariantGate.clear(sessionInfo.id)
|
||||
clearSessionModel(sessionInfo.id)
|
||||
syncSubagentSessions.delete(sessionInfo.id)
|
||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
|
||||
await lspManager.cleanupTempDirectoryClients()
|
||||
clearSessionAgent(sessionInfo.id);
|
||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||
lastKnownModelBySession.delete(sessionInfo.id);
|
||||
clearPendingModelFallback(sessionInfo.id);
|
||||
clearSessionFallbackChain(sessionInfo.id);
|
||||
resetMessageCursor(sessionInfo.id);
|
||||
firstMessageVariantGate.clear(sessionInfo.id);
|
||||
clearSessionModel(sessionInfo.id);
|
||||
syncSubagentSessions.delete(sessionInfo.id);
|
||||
deleteSessionTools(sessionInfo.id);
|
||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
await managers.tmuxSessionManager.onSessionDeleted({
|
||||
sessionID: sessionInfo.id,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const agent = info?.agent as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined;
|
||||
const sessionID = info?.sessionID as string | undefined;
|
||||
const agent = info?.agent as string | undefined;
|
||||
const role = info?.role as string | undefined;
|
||||
if (sessionID && role === "user") {
|
||||
if (agent) {
|
||||
updateSessionAgent(sessionID, agent)
|
||||
updateSessionAgent(sessionID, agent);
|
||||
}
|
||||
const providerID = info?.providerID as string | undefined
|
||||
const modelID = info?.modelID as string | undefined
|
||||
const providerID = info?.providerID as string | undefined;
|
||||
const modelID = info?.modelID as string | undefined;
|
||||
if (providerID && modelID) {
|
||||
lastKnownModelBySession.set(sessionID, { providerID, modelID })
|
||||
setSessionModel(sessionID, { providerID, modelID })
|
||||
lastKnownModelBySession.set(sessionID, { providerID, modelID });
|
||||
setSessionModel(sessionID, { providerID, modelID });
|
||||
}
|
||||
}
|
||||
|
||||
// Model fallback: in practice, API/model failures often surface as assistant message errors.
|
||||
// session.error events are not guaranteed for all providers, so we also observe message.updated.
|
||||
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
|
||||
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
|
||||
try {
|
||||
const assistantMessageID = info?.id as string | undefined
|
||||
const assistantError = info?.error
|
||||
const assistantMessageID = info?.id as string | undefined;
|
||||
const assistantError = info?.error;
|
||||
if (assistantMessageID && assistantError) {
|
||||
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
|
||||
const lastHandled = lastHandledModelErrorMessageID.get(sessionID);
|
||||
if (lastHandled === assistantMessageID) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const errorName = extractErrorName(assistantError)
|
||||
const errorMessage = extractErrorMessage(assistantError)
|
||||
const errorInfo = { name: errorName, message: errorMessage }
|
||||
const errorName = extractErrorName(assistantError);
|
||||
const errorMessage = extractErrorMessage(assistantError);
|
||||
const errorInfo = { name: errorName, message: errorMessage };
|
||||
|
||||
if (shouldRetryError(errorInfo)) {
|
||||
// Prefer the agent/model/provider from the assistant message payload.
|
||||
let agentName = agent ?? getSessionAgent(sessionID)
|
||||
let agentName = agent ?? getSessionAgent(sessionID);
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
} else if (errorMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus"
|
||||
agentName = "hephaestus";
|
||||
} else {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
|
||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
|
||||
const currentModel = normalizeFallbackModelID(rawModel)
|
||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
|
||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
|
||||
const currentModel = normalizeFallbackModelID(rawModel);
|
||||
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
|
||||
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
|
||||
log("[event] model-fallback error in message.updated:", { sessionID, error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const status = props?.status as
|
||||
| { type?: string; attempt?: number; message?: string; next?: number }
|
||||
| undefined
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
|
||||
|
||||
if (sessionID && status?.type === "retry") {
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
|
||||
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
lastHandledRetryStatusKey.set(sessionID, retryKey)
|
||||
lastHandledRetryStatusKey.set(sessionID, retryKey);
|
||||
|
||||
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
|
||||
const errorInfo = { name: undefined as string | undefined, message: retryMessage };
|
||||
if (shouldRetryError(errorInfo)) {
|
||||
let agentName = getSessionAgent(sessionID)
|
||||
let agentName = getSessionAgent(sessionID);
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
} else if (retryMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus"
|
||||
agentName = "hephaestus";
|
||||
} else {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const parsed = extractProviderModelFromErrorMessage(retryMessage)
|
||||
const lastKnown = lastKnownModelBySession.get(sessionID)
|
||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
|
||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
|
||||
currentModel = normalizeFallbackModelID(currentModel)
|
||||
const parsed = extractProviderModelFromErrorMessage(retryMessage);
|
||||
const lastKnown = lastKnownModelBySession.get(sessionID);
|
||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
|
||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("[event] model-fallback error in session.status:", { sessionID, error: err })
|
||||
log("[event] model-fallback error in session.status:", { sessionID, error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
try {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const error = props?.error;
|
||||
|
||||
const errorName = extractErrorName(error)
|
||||
const errorMessage = extractErrorMessage(error)
|
||||
const errorInfo = { name: errorName, message: errorMessage }
|
||||
const errorName = extractErrorName(error);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const errorInfo = { name: errorName, message: errorMessage };
|
||||
|
||||
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
|
||||
if (hooks.sessionRecovery?.isRecoverableError(error)) {
|
||||
@@ -405,8 +406,8 @@ export function createEventHandler(args: {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
}
|
||||
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
};
|
||||
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
|
||||
if (
|
||||
recovered &&
|
||||
@@ -420,53 +421,52 @@ export function createEventHandler(args: {
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
|
||||
let agentName = getSessionAgent(sessionID)
|
||||
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
|
||||
let agentName = getSessionAgent(sessionID);
|
||||
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
} else if (errorMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus"
|
||||
agentName = "hephaestus";
|
||||
} else {
|
||||
agentName = "sisyphus"
|
||||
agentName = "sisyphus";
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const parsed = extractProviderModelFromErrorMessage(errorMessage)
|
||||
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
|
||||
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
|
||||
currentModel = normalizeFallbackModelID(currentModel)
|
||||
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
if (agentName) {
|
||||
const parsed = extractProviderModelFromErrorMessage(errorMessage);
|
||||
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
|
||||
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
log("[event] model-fallback error in session.error:", { sessionID, error: err })
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createSessionHooks(args: {
|
||||
isHookEnabled("preemptive-compaction") &&
|
||||
pluginConfig.experimental?.preemptive_compaction
|
||||
? safeHook("preemptive-compaction", () =>
|
||||
createPreemptiveCompactionHook(ctx, modelCacheState))
|
||||
createPreemptiveCompactionHook(ctx, pluginConfig, modelCacheState))
|
||||
: null
|
||||
|
||||
const sessionRecovery = isHookEnabled("session-recovery")
|
||||
@@ -151,9 +151,10 @@ export function createSessionHooks(args: {
|
||||
}
|
||||
}
|
||||
|
||||
// Model fallback hook (configurable via disabled_hooks)
|
||||
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
|
||||
// This handles automatic model switching when model errors occur
|
||||
const modelFallback = isHookEnabled("model-fallback")
|
||||
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
|
||||
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
|
||||
? safeHook("model-fallback", () =>
|
||||
createModelFallbackHook({
|
||||
toast: async ({ title, message, variant, duration }) => {
|
||||
@@ -174,7 +175,7 @@ export function createSessionHooks(args: {
|
||||
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
||||
? safeHook("anthropic-context-window-limit-recovery", () =>
|
||||
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
|
||||
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental, pluginConfig }))
|
||||
: null
|
||||
|
||||
const autoUpdateChecker = isHookEnabled("auto-update-checker")
|
||||
|
||||
33
src/plugin/tool-execute-before-session-notification.test.ts
Normal file
33
src/plugin/tool-execute-before-session-notification.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const { describe, expect, test, spyOn } = require("bun:test")
|
||||
|
||||
const sessionState = require("../features/claude-code-session-state")
|
||||
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
|
||||
|
||||
describe("createToolExecuteBeforeHandler session notification sessionID", () => {
|
||||
test("uses main session fallback when input sessionID is empty", async () => {
|
||||
const mainSessionID = "ses_main"
|
||||
const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID)
|
||||
|
||||
let capturedSessionID: string | undefined
|
||||
const hooks = {
|
||||
sessionNotification: async (input) => {
|
||||
capturedSessionID = input.event.properties?.sessionID
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({
|
||||
ctx: { client: { session: { messages: async () => ({ data: [] }) } } },
|
||||
hooks,
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "question", sessionID: "", callID: "call_q" },
|
||||
{ args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } },
|
||||
)
|
||||
|
||||
expect(getMainSessionIDSpy).toHaveBeenCalled()
|
||||
expect(capturedSessionID).toBe(mainSessionID)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -31,6 +31,60 @@ describe("createToolExecuteBeforeHandler", () => {
|
||||
await expect(run).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("triggers session notification hook for question tools", async () => {
|
||||
let called = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const hooks = {
|
||||
sessionNotification: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
|
||||
called = true
|
||||
expect(input.event.type).toBe("tool.execute.before")
|
||||
expect(input.event.properties?.sessionID).toBe("ses_q")
|
||||
expect(input.event.properties?.tool).toBe("question")
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
|
||||
const input = { tool: "question", sessionID: "ses_q", callID: "call_q" }
|
||||
const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record<string, unknown> }
|
||||
|
||||
await handler(input, output)
|
||||
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
|
||||
test("does not trigger session notification hook for non-question tools", async () => {
|
||||
let called = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const hooks = {
|
||||
sessionNotification: async () => {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
|
||||
|
||||
await handler(
|
||||
{ tool: "bash", sessionID: "ses_b", callID: "call_b" },
|
||||
{ args: { command: "pwd" } as Record<string, unknown> },
|
||||
)
|
||||
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
describe("task tool subagent_type normalization", () => {
|
||||
const emptyHooks = {}
|
||||
|
||||
|
||||
@@ -30,6 +30,26 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
|
||||
|
||||
const normalizedToolName = input.tool.toLowerCase()
|
||||
if (
|
||||
normalizedToolName === "question"
|
||||
|| normalizedToolName === "ask_user_question"
|
||||
|| normalizedToolName === "askuserquestion"
|
||||
) {
|
||||
const sessionID = input.sessionID || getMainSessionID()
|
||||
await hooks.sessionNotification?.({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: {
|
||||
sessionID,
|
||||
tool: input.tool,
|
||||
args: output.args,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (input.tool === "task") {
|
||||
const argsObject = output.args
|
||||
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
|
||||
|
||||
@@ -1,78 +1,129 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { getHomeDirectory } from "./home-directory"
|
||||
import { findBashPath, findZshPath } from "./shell-path"
|
||||
import { spawn } from "node:child_process";
|
||||
import { getHomeDirectory } from "./home-directory";
|
||||
import { findBashPath, findZshPath } from "./shell-path";
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exitCode: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
||||
const SIGKILL_GRACE_MS = 5_000;
|
||||
|
||||
export interface ExecuteHookOptions {
|
||||
forceZsh?: boolean
|
||||
zshPath?: string
|
||||
forceZsh?: boolean;
|
||||
zshPath?: string;
|
||||
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function executeHookCommand(
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions,
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions,
|
||||
): Promise<CommandResult> {
|
||||
const home = getHomeDirectory()
|
||||
const home = getHomeDirectory();
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
||||
|
||||
const expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
|
||||
const expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
|
||||
|
||||
let finalCommand = expandedCommand
|
||||
let finalCommand = expandedCommand;
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = findZshPath(options.zshPath)
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
if (zshPath) {
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
} else {
|
||||
const bashPath = findBashPath()
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = findZshPath(options.zshPath);
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
|
||||
if (zshPath) {
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`;
|
||||
} else {
|
||||
const bashPath = findBashPath();
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
})
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
const isWin32 = process.platform === "win32";
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
detached: !isWin32,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
});
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stdin?.write(stdin)
|
||||
proc.stdin?.end()
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
})
|
||||
})
|
||||
proc.stdin?.on("error", () => {});
|
||||
proc.stdin?.write(stdin);
|
||||
proc.stdin?.end();
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({ exitCode: 1, stderr: err.message })
|
||||
})
|
||||
})
|
||||
const settle = (result: CommandResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
proc.on("close", code => {
|
||||
settle({
|
||||
exitCode: code ?? 1,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", err => {
|
||||
settle({ exitCode: 1, stderr: err.message });
|
||||
});
|
||||
|
||||
const killProcessGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (!isWin32 && proc.pid) {
|
||||
try {
|
||||
process.kill(-proc.pid, signal);
|
||||
} catch {
|
||||
proc.kill(signal);
|
||||
}
|
||||
} else {
|
||||
proc.kill(signal);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
// Kill entire process group to avoid orphaned children
|
||||
killProcessGroup("SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
killProcessGroup("SIGKILL");
|
||||
}, SIGKILL_GRACE_MS);
|
||||
// Append timeout notice to stderr
|
||||
stderr += `\nHook command timed out after ${timeoutMs}ms`;
|
||||
}, timeoutMs);
|
||||
|
||||
// Don't let the timeout timer keep the process alive
|
||||
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
|
||||
timeoutTimer.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function fetchAvailableModels(
|
||||
// Handle both string[] (legacy) and object[] (with metadata) formats
|
||||
const modelId = typeof modelItem === 'string'
|
||||
? modelItem
|
||||
: (modelItem as any)?.id
|
||||
: modelItem?.id
|
||||
|
||||
if (modelId) {
|
||||
modelSet.add(`${providerId}/${modelId}`)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getOpenCodeCacheDir } from "./data-path"
|
||||
import { log } from "./logger"
|
||||
import { isRecord } from "./record-type-guard"
|
||||
|
||||
export function addModelsFromModelsJsonCache(
|
||||
connectedProviders: Set<string>,
|
||||
modelSet: Set<string>,
|
||||
): boolean {
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[fetchAvailableModels] models.json cache file not found, falling back to client")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data: unknown = JSON.parse(content)
|
||||
if (!isRecord(data)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const providerIds = Object.keys(data)
|
||||
log("[fetchAvailableModels] providers found in models.json", {
|
||||
count: providerIds.length,
|
||||
providers: providerIds.slice(0, 10),
|
||||
})
|
||||
|
||||
const previousSize = modelSet.size
|
||||
for (const providerId of providerIds) {
|
||||
if (!connectedProviders.has(providerId)) continue
|
||||
const providerValue = data[providerId]
|
||||
if (!isRecord(providerValue)) continue
|
||||
const modelsValue = providerValue["models"]
|
||||
if (!isRecord(modelsValue)) continue
|
||||
for (const modelKey of Object.keys(modelsValue)) {
|
||||
modelSet.add(`${providerId}/${modelKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: Array.from(connectedProviders).slice(0, 5),
|
||||
})
|
||||
|
||||
return modelSet.size > previousSize
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] error", { error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ModelListFunction, ProviderListFunction } from "./open-code-client-shapes"
|
||||
import { isRecord } from "./record-type-guard"
|
||||
|
||||
export function getProviderListFunction(client: unknown): ProviderListFunction | null {
|
||||
if (!isRecord(client)) return null
|
||||
const provider = client["provider"]
|
||||
if (!isRecord(provider)) return null
|
||||
const list = provider["list"]
|
||||
if (typeof list !== "function") return null
|
||||
return list as ProviderListFunction
|
||||
}
|
||||
|
||||
export function getModelListFunction(client: unknown): ModelListFunction | null {
|
||||
if (!isRecord(client)) return null
|
||||
const model = client["model"]
|
||||
if (!isRecord(model)) return null
|
||||
const list = model["list"]
|
||||
if (typeof list !== "function") return null
|
||||
return list as ModelListFunction
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type ProviderListResponse = { data?: { connected?: string[] } }
|
||||
export type ModelListResponse = {
|
||||
data?: Array<{ id?: string; provider?: string }>
|
||||
}
|
||||
|
||||
export type ProviderListFunction = () => Promise<ProviderListResponse>
|
||||
export type ModelListFunction = () => Promise<ModelListResponse>
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readProviderModelsCache } from "./connected-providers-cache"
|
||||
import { log } from "./logger"
|
||||
|
||||
export function addModelsFromProviderModelsCache(
|
||||
connectedProviders: Set<string>,
|
||||
modelSet: Set<string>,
|
||||
): boolean {
|
||||
const providerModelsCache = readProviderModelsCache()
|
||||
if (!providerModelsCache) {
|
||||
return false
|
||||
}
|
||||
|
||||
const providerCount = Object.keys(providerModelsCache.models).length
|
||||
if (providerCount === 0) {
|
||||
log("[fetchAvailableModels] provider-models cache empty, falling back to models.json")
|
||||
return false
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
|
||||
const previousSize = modelSet.size
|
||||
|
||||
for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) {
|
||||
if (!connectedProviders.has(providerId)) continue
|
||||
for (const modelItem of modelIds) {
|
||||
if (!modelItem) continue
|
||||
const modelId = typeof modelItem === "string" ? modelItem : modelItem.id
|
||||
if (modelId) {
|
||||
modelSet.add(`${providerId}/${modelId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed from provider-models cache", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: Array.from(connectedProviders).slice(0, 5),
|
||||
})
|
||||
|
||||
return modelSet.size > previousSize
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
const store = new Map<string, Record<string, boolean>>()
|
||||
const store = new Map<string, Record<string, boolean>>();
|
||||
|
||||
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
|
||||
store.set(sessionID, { ...tools })
|
||||
store.set(sessionID, { ...tools });
|
||||
}
|
||||
|
||||
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
|
||||
const tools = store.get(sessionID)
|
||||
return tools ? { ...tools } : undefined
|
||||
const tools = store.get(sessionID);
|
||||
return tools ? { ...tools } : undefined;
|
||||
}
|
||||
|
||||
export function deleteSessionTools(sessionID: string): void {
|
||||
store.delete(sessionID);
|
||||
}
|
||||
|
||||
export function clearSessionTools(): void {
|
||||
store.clear()
|
||||
store.clear();
|
||||
}
|
||||
|
||||
@@ -77,13 +77,40 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
|
||||
storeToolMetadata(ctx.sessionID, callID, meta)
|
||||
}
|
||||
|
||||
const isActive = task.status === "pending" || task.status === "running"
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
const fullSession = args.full_session ?? true
|
||||
|
||||
let resolvedTask = task
|
||||
|
||||
if (shouldBlock && (task.status === "pending" || task.status === "running")) {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await delay(1000)
|
||||
|
||||
const currentTask = manager.getTask(args.task_id)
|
||||
if (!currentTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
|
||||
if (currentTask.status !== "pending" && currentTask.status !== "running") {
|
||||
resolvedTask = currentTask
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const finalCheck = manager.getTask(args.task_id)
|
||||
if (finalCheck) {
|
||||
resolvedTask = finalCheck
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = resolvedTask.status === "pending" || resolvedTask.status === "running"
|
||||
const includeThinking = isActive || (args.include_thinking ?? false)
|
||||
const includeToolResults = isActive || (args.include_tool_results ?? false)
|
||||
|
||||
if (fullSession) {
|
||||
return await formatFullSession(task, client, {
|
||||
return await formatFullSession(resolvedTask, client, {
|
||||
includeThinking,
|
||||
messageLimit: args.message_limit,
|
||||
sinceMessageId: args.since_message_id,
|
||||
@@ -92,44 +119,15 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
|
||||
})
|
||||
}
|
||||
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
|
||||
if (task.status === "completed") {
|
||||
return await formatTaskResult(task, client)
|
||||
if (resolvedTask.status === "completed") {
|
||||
return await formatTaskResult(resolvedTask, client)
|
||||
}
|
||||
|
||||
if (task.status === "error" || task.status === "cancelled" || task.status === "interrupt") {
|
||||
return formatTaskStatus(task)
|
||||
if (resolvedTask.status === "error" || resolvedTask.status === "cancelled" || resolvedTask.status === "interrupt") {
|
||||
return formatTaskStatus(resolvedTask)
|
||||
}
|
||||
|
||||
if (!shouldBlock) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await delay(1000)
|
||||
|
||||
const currentTask = manager.getTask(args.task_id)
|
||||
if (!currentTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
|
||||
if (currentTask.status === "completed") {
|
||||
return await formatTaskResult(currentTask, client)
|
||||
}
|
||||
|
||||
if (currentTask.status === "error" || currentTask.status === "cancelled" || currentTask.status === "interrupt") {
|
||||
return formatTaskStatus(currentTask)
|
||||
}
|
||||
}
|
||||
|
||||
const finalTask = manager.getTask(args.task_id)
|
||||
if (!finalTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
|
||||
return formatTaskStatus(resolvedTask)
|
||||
} catch (error) {
|
||||
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
@@ -339,6 +339,48 @@ describe("background_output full_session", () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("background_output blocking", () => {
|
||||
test("block=true waits for task completion even with default full_session=true", async () => {
|
||||
// #given a task that transitions running → completed after 2 polls
|
||||
let pollCount = 0
|
||||
const task = createTask({ status: "running" })
|
||||
const manager: BackgroundOutputManager = {
|
||||
getTask: (id: string) => {
|
||||
if (id !== task.id) return undefined
|
||||
pollCount++
|
||||
if (pollCount >= 3) {
|
||||
task.status = "completed"
|
||||
}
|
||||
return task
|
||||
},
|
||||
}
|
||||
const client = createMockClient({
|
||||
"ses-1": [
|
||||
{
|
||||
id: "m1",
|
||||
info: { role: "assistant", time: "2026-01-01T00:00:00Z" },
|
||||
parts: [{ type: "text", text: "completed result" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const tool = createBackgroundOutput(manager, client)
|
||||
|
||||
// #when block=true, full_session not specified (defaults to true)
|
||||
const output = await tool.execute({
|
||||
task_id: "task-1",
|
||||
block: true,
|
||||
timeout: 10000,
|
||||
}, mockContext)
|
||||
|
||||
// #then should have waited and returned full session output
|
||||
expect(task.status).toBe("completed")
|
||||
expect(pollCount).toBeGreaterThanOrEqual(3)
|
||||
expect(output).toContain("# Full Session Output")
|
||||
expect(output).toContain("completed result")
|
||||
})
|
||||
})
|
||||
|
||||
describe("background_cancel", () => {
|
||||
test("cancels a running task via manager", async () => {
|
||||
// #given
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared"
|
||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { role?: string; time?: { created?: number } }
|
||||
parts?: Array<{ type: string; text?: string; content?: string | Array<{ type: string; text?: string }> }>
|
||||
}
|
||||
|
||||
export async function processMessages(
|
||||
sessionID: string,
|
||||
ctx: PluginInput
|
||||
@@ -20,9 +25,8 @@ export async function processMessages(
|
||||
|
||||
// Include both assistant messages AND tool messages
|
||||
// Tool results (grep, glob, bash output) come from role "tool"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const relevantMessages = messages.filter(
|
||||
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
(m: SDKMessage) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (relevantMessages.length === 0) {
|
||||
@@ -34,8 +38,7 @@ export async function processMessages(
|
||||
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
|
||||
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
|
||||
const sortedMessages = [...relevantMessages].sort((a: SDKMessage, b: SDKMessage) => {
|
||||
const timeA = a.info?.time?.created ?? 0
|
||||
const timeB = b.info?.time?.created ?? 0
|
||||
return timeA - timeB
|
||||
@@ -52,12 +55,11 @@ export async function processMessages(
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of newMessages) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const part of (message as any).parts ?? []) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extractedContent.push(part.text)
|
||||
} else if (part.type === "tool_result") {
|
||||
} else if ((part.type as string) === "tool_result") {
|
||||
// Tool results contain the actual output from tool calls
|
||||
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
||||
if (typeof toolResult.content === "string" && toolResult.content) {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared"
|
||||
|
||||
function getSessionStatusType(statusResult: unknown, sessionID: string): string | null {
|
||||
if (typeof statusResult !== "object" || statusResult === null) return null
|
||||
if (!("data" in statusResult)) return null
|
||||
const data = (statusResult as { data?: unknown }).data
|
||||
if (typeof data !== "object" || data === null) return null
|
||||
const record = data as Record<string, unknown>
|
||||
const entry = record[sessionID]
|
||||
if (typeof entry !== "object" || entry === null) return null
|
||||
const typeValue = (entry as Record<string, unknown>)["type"]
|
||||
return typeof typeValue === "string" ? typeValue : null
|
||||
}
|
||||
|
||||
function getMessagesArray(result: unknown): unknown[] {
|
||||
if (Array.isArray(result)) return result
|
||||
if (typeof result !== "object" || result === null) return []
|
||||
if (!("data" in result)) return []
|
||||
const data = (result as { data?: unknown }).data
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function waitForSessionCompletion(
|
||||
ctx: PluginInput,
|
||||
options: {
|
||||
sessionID: string
|
||||
abortSignal?: AbortSignal
|
||||
maxPollTimeMs: number
|
||||
pollIntervalMs: number
|
||||
stabilityRequired: number
|
||||
},
|
||||
): Promise<{ ok: true } | { ok: false; reason: "aborted" | "timeout" }> {
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
while (Date.now() - pollStart < options.maxPollTimeMs) {
|
||||
if (options.abortSignal?.aborted) {
|
||||
log("[call_omo_agent] Aborted by user")
|
||||
return { ok: false, reason: "aborted" }
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, options.pollIntervalMs)
|
||||
})
|
||||
|
||||
const statusResult = await ctx.client.session.status()
|
||||
const sessionStatusType = getSessionStatusType(statusResult, options.sessionID)
|
||||
|
||||
if (sessionStatusType && sessionStatusType !== "idle") {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesCheck = await ctx.client.session.messages({
|
||||
path: { id: options.sessionID },
|
||||
})
|
||||
const currentMsgCount = getMessagesArray(messagesCheck).length
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= options.stabilityRequired) {
|
||||
log("[call_omo_agent] Session complete", { messageCount: currentMsgCount })
|
||||
return { ok: true }
|
||||
}
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
log("[call_omo_agent] Timeout reached")
|
||||
return { ok: false, reason: "timeout" }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export async function createOrGetSession(
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `${args.description} (@${args.subagent_type} subagent)`,
|
||||
} as any,
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { consumeNewMessages, type CursorMessage } from "../../shared/session-cursor"
|
||||
|
||||
type SessionMessagePart = {
|
||||
type: string
|
||||
text?: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
export type SessionMessage = CursorMessage & {
|
||||
info?: CursorMessage["info"] & { role?: string }
|
||||
parts?: SessionMessagePart[]
|
||||
}
|
||||
|
||||
function getRole(message: SessionMessage): string | null {
|
||||
const role = message.info?.role
|
||||
return typeof role === "string" ? role : null
|
||||
}
|
||||
|
||||
function getCreatedTime(message: SessionMessage): number {
|
||||
const time = message.info?.time
|
||||
if (typeof time === "number") return time
|
||||
if (typeof time === "string") return Number(time) || 0
|
||||
const created = time?.created
|
||||
if (typeof created === "number") return created
|
||||
if (typeof created === "string") return Number(created) || 0
|
||||
return 0
|
||||
}
|
||||
|
||||
function isRelevantRole(role: string | null): boolean {
|
||||
return role === "assistant" || role === "tool"
|
||||
}
|
||||
|
||||
function extractTextFromParts(parts: SessionMessagePart[] | undefined): string[] {
|
||||
if (!parts) return []
|
||||
const extracted: string[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extracted.push(part.text)
|
||||
continue
|
||||
}
|
||||
if (part.type !== "tool_result") continue
|
||||
const content = part.content
|
||||
if (typeof content === "string" && content) {
|
||||
extracted.push(content)
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) continue
|
||||
const record = block as Record<string, unknown>
|
||||
const typeValue = record["type"]
|
||||
const textValue = record["text"]
|
||||
if (
|
||||
(typeValue === "text" || typeValue === "reasoning") &&
|
||||
typeof textValue === "string" &&
|
||||
textValue
|
||||
) {
|
||||
extracted.push(textValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extracted
|
||||
}
|
||||
|
||||
export function extractNewSessionOutput(
|
||||
sessionID: string,
|
||||
messages: SessionMessage[],
|
||||
): { output: string; hasNewOutput: boolean } {
|
||||
const relevantMessages = messages.filter((message) =>
|
||||
isRelevantRole(getRole(message)),
|
||||
)
|
||||
if (relevantMessages.length === 0) {
|
||||
return { output: "", hasNewOutput: false }
|
||||
}
|
||||
|
||||
const sortedMessages = [...relevantMessages].sort(
|
||||
(a, b) => getCreatedTime(a) - getCreatedTime(b),
|
||||
)
|
||||
const newMessages = consumeNewMessages(sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
return { output: "", hasNewOutput: false }
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
for (const message of newMessages) {
|
||||
chunks.push(...extractTextFromParts(message.parts))
|
||||
}
|
||||
|
||||
const output = chunks.filter((text) => text.length > 0).join("\n\n")
|
||||
return { output, hasNewOutput: output.length > 0 }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user