Compare commits
72 Commits
v3.8.2
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deef9d864b | ||
|
|
55b9ad60d8 | ||
|
|
e997e0071c | ||
|
|
b8257dc59c | ||
|
|
365d863e3a | ||
|
|
1785313f3b | ||
|
|
ac962d62ab | ||
|
|
d61c0f8cb5 | ||
|
|
a567cd0d68 | ||
|
|
55ad4297d4 | ||
|
|
c6a69899d8 | ||
|
|
2aeb96c3f6 | ||
|
|
5fd65f2935 | ||
|
|
b03aae57f3 | ||
|
|
8c3a0ca2fe | ||
|
|
9a2e0f1add | ||
|
|
d28ebd10c1 | ||
|
|
fb92babee7 | ||
|
|
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 |
@@ -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
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.8.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"oh-my-opencode-darwin-x64": "3.8.2",
|
||||
"oh-my-opencode-linux-arm64": "3.8.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.2",
|
||||
"oh-my-opencode-linux-x64": "3.8.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.2",
|
||||
"oh-my-opencode-windows-x64": "3.8.2"
|
||||
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||
"oh-my-opencode-darwin-x64": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
||||
"oh-my-opencode-linux-x64": "3.8.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
||||
"oh-my-opencode-windows-x64": "3.8.5"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.8.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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.2",
|
||||
"version": "3.8.5",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +1,2 @@
|
||||
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||
export { ATLAS_GEMINI_SYSTEM_PROMPT, getGeminiAtlasPrompt } from "./gemini"
|
||||
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,16 +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"
|
||||
export { PROMETHEUS_GEMINI_SYSTEM_PROMPT, getGeminiPrometheusPrompt } from "./gemini"
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildGeminiToolMandate,
|
||||
buildGeminiDelegationOverride,
|
||||
buildGeminiVerificationOverride,
|
||||
buildGeminiIntentGateEnforcement,
|
||||
} from "./sisyphus-gemini-overlays";
|
||||
|
||||
const MODE: AgentMode = "primary";
|
||||
@@ -335,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
|
||||
|
||||
@@ -477,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}
|
||||
@@ -567,7 +567,7 @@ export function createSisyphusAgent(
|
||||
if (isGeminiModel(model)) {
|
||||
prompt = prompt.replace(
|
||||
"</intent_verbalization>",
|
||||
`</intent_verbalization>\n\n${buildGeminiToolMandate()}`
|
||||
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
|
||||
);
|
||||
prompt += "\n" + buildGeminiDelegationOverride();
|
||||
prompt += "\n" + buildGeminiVerificationOverride();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
59
src/features/claude-code-command-loader/loader.test.ts
Normal file
59
src/features/claude-code-command-loader/loader.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
|
||||
|
||||
const testRoots: string[] = []
|
||||
|
||||
function createTempRoot(): string {
|
||||
const root = join(tmpdir(), `command-loader-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
mkdirSync(root, { recursive: true })
|
||||
testRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writeCommand(dir: string, name: string): void {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, `${name}.md`),
|
||||
"---\ndescription: command from test\n---\nUse this command"
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of testRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
})
|
||||
|
||||
describe("claude-code-command-loader OpenCode paths", () => {
|
||||
it("loads commands from global OpenCode commands directory", async () => {
|
||||
// given
|
||||
const root = createTempRoot()
|
||||
const opencodeConfigDir = join(root, "config")
|
||||
writeCommand(join(opencodeConfigDir, "commands"), "global-opencode")
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeGlobalCommands()
|
||||
|
||||
// then
|
||||
expect(commands["global-opencode"]).toBeDefined()
|
||||
})
|
||||
|
||||
it("loads commands from project OpenCode commands directory", async () => {
|
||||
// given
|
||||
const root = createTempRoot()
|
||||
writeCommand(join(root, ".opencode", "commands"), "project-opencode")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(root)
|
||||
|
||||
// then
|
||||
expect(commands["project-opencode"]).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -122,13 +122,13 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
|
||||
|
||||
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const opencodeCommandsDir = join(configDir, "command")
|
||||
const opencodeCommandsDir = join(configDir, "commands")
|
||||
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "commands")
|
||||
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
src/hooks/auto-slash-command/executor.test.ts
Normal file
63
src/hooks/auto-slash-command/executor.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
|
||||
const testRoots: string[] = []
|
||||
|
||||
function createTempRoot(): string {
|
||||
const root = join(tmpdir(), `auto-slash-executor-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
mkdirSync(root, { recursive: true })
|
||||
testRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writeCommand(dir: string, name: string): void {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, `${name}.md`),
|
||||
"---\ndescription: command from test\n---\nRun from OpenCode command directory"
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of testRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
})
|
||||
|
||||
describe("auto-slash-command executor OpenCode paths", () => {
|
||||
it("resolves commands from OpenCode global and project plural directories", async () => {
|
||||
// given
|
||||
const root = createTempRoot()
|
||||
const opencodeConfigDir = join(root, "config")
|
||||
writeCommand(join(opencodeConfigDir, "commands"), "global-cmd")
|
||||
writeCommand(join(root, ".opencode", "commands"), "project-cmd")
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(root)
|
||||
|
||||
try {
|
||||
// when
|
||||
const globalResult = await executeSlashCommand(
|
||||
{ command: "global-cmd", args: "", raw: "/global-cmd" },
|
||||
{ skills: [] }
|
||||
)
|
||||
const projectResult = await executeSlashCommand(
|
||||
{ command: "project-cmd", args: "", raw: "/project-cmd" },
|
||||
{ skills: [] }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(globalResult.success).toBe(true)
|
||||
expect(projectResult.success).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -105,8 +105,8 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(configDir, "command")
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const opencodeGlobalDir = join(configDir, "commands")
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "commands")
|
||||
|
||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||
|
||||
@@ -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"
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||
import { toHashlineContent } from "../../tools/hashline-edit/diff-utils"
|
||||
|
||||
const WRITE_SUCCESS_MARKER = "File written successfully."
|
||||
|
||||
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>"
|
||||
const OPENCODE_LINE_TRUNCATION_SUFFIX = "... (line truncated to 2000 chars)"
|
||||
|
||||
function isReadTool(toolName: string): boolean {
|
||||
return toolName.toLowerCase() === "read"
|
||||
@@ -24,18 +29,39 @@ 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}`
|
||||
if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) {
|
||||
return line
|
||||
}
|
||||
const hash = computeLineHash(parsed.lineNumber, parsed.content)
|
||||
return `${parsed.lineNumber}#${hash}|${parsed.content}`
|
||||
}
|
||||
|
||||
function transformOutput(output: string): string {
|
||||
@@ -44,25 +70,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 +115,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 +142,12 @@ 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.startsWith(WRITE_SUCCESS_MARKER)) {
|
||||
return
|
||||
}
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
if (outputLower.startsWith("error") || outputLower.includes("failed")) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,8 +162,8 @@ 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}`
|
||||
const lineCount = content === "" ? 0 : content.split("\n").length
|
||||
output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.`
|
||||
}
|
||||
|
||||
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,70 @@ 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("keeps OpenCode-truncated lines unhashed while hashifying normal lines", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "read", sessionID: "s", callID: "c" }
|
||||
const truncatedLine = `${"x".repeat(60)}... (line truncated to 2000 chars)`
|
||||
const output = {
|
||||
title: "demo.ts",
|
||||
output: [
|
||||
"<path>/tmp/demo.ts</path>",
|
||||
"<type>file</type>",
|
||||
"<content>",
|
||||
`1: ${truncatedLine}`,
|
||||
"2: normal line",
|
||||
"</content>",
|
||||
].join("\n"),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[3]).toBe(`1: ${truncatedLine}`)
|
||||
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|normal line$/)
|
||||
})
|
||||
|
||||
it("hashifies plain read output without content tags", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
@@ -71,13 +132,66 @@ 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("appends LINE#ID output for write tool using metadata filepath", async () => {
|
||||
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 simple summary for write tool instead of full hashlined content", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-"))
|
||||
@@ -94,9 +208,55 @@ 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("File written successfully.")
|
||||
expect(output.output).toContain("2 lines written.")
|
||||
expect(output.output).not.toContain("Updated file (LINE#ID|content):")
|
||||
expect(output.output).not.toContain("const x = 1")
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not re-process write output that already contains the success marker", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-idem-"))
|
||||
const filePath = path.join(tempDir, "demo.ts")
|
||||
fs.writeFileSync(filePath, "a\nb\nc\nd\ne")
|
||||
const input = { tool: "write", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "write",
|
||||
output: "File written successfully. 99 lines written.",
|
||||
metadata: { filepath: filePath },
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then — guard should prevent re-reading the file and updating the count
|
||||
expect(output.output).toBe("File written successfully. 99 lines written.")
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not overwrite write tool error output with success message", async () => {
|
||||
//#given — write tool failed, but stale file exists from previous write
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-err-"))
|
||||
const filePath = path.join(tempDir, "demo.ts")
|
||||
fs.writeFileSync(filePath, "const x = 1")
|
||||
const input = { tool: "write", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "write",
|
||||
output: "Error: EACCES: permission denied, open '" + filePath + "'",
|
||||
metadata: { filepath: filePath },
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then — error output must be preserved, not overwritten with success message
|
||||
expect(output.output).toContain("Error: EACCES")
|
||||
expect(output.output).not.toContain("File written successfully.")
|
||||
|
||||
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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +126,9 @@ export function createEventHandler(args: {
|
||||
? args.pluginConfig.runtime_fallback
|
||||
: (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>();
|
||||
@@ -271,7 +274,7 @@ export function createEventHandler(args: {
|
||||
|
||||
// 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;
|
||||
@@ -334,7 +337,7 @@ export function createEventHandler(args: {
|
||||
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}`;
|
||||
@@ -422,7 +425,7 @@ export function createEventHandler(args: {
|
||||
}
|
||||
}
|
||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
|
||||
let agentName = getSessionAgent(sessionID);
|
||||
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log, getAgentToolRestrictions } from "../../shared"
|
||||
|
||||
export async function promptSubagentSession(
|
||||
ctx: PluginInput,
|
||||
options: { sessionID: string; agent: string; prompt: string },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
agent: options.agent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(options.agent),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: options.prompt }],
|
||||
},
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log("[call_omo_agent] Prompt error", { error: errorMessage })
|
||||
return { ok: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { createOrGetSession } from "./session-creator"
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
import { processMessages } from "./message-processor"
|
||||
|
||||
type SessionWithPromptAsync = {
|
||||
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
||||
}
|
||||
|
||||
type ExecuteSyncDeps = {
|
||||
createOrGetSession: typeof createOrGetSession
|
||||
waitForCompletion: typeof waitForCompletion
|
||||
@@ -41,7 +45,7 @@ export async function executeSync(
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
|
||||
try {
|
||||
await (ctx.client.session as any).promptAsync({
|
||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function createSyncSession(
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agentToUse} subagent)`,
|
||||
} as any,
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export function stripMergeOperatorChars(text: string): string {
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
@@ -36,7 +37,9 @@ export function restoreOldWrappedLines(originalLines: string[], replacementLines
|
||||
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
|
||||
for (let start = 0; start < replacementLines.length; start += 1) {
|
||||
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
|
||||
const canonicalSpan = stripAllWhitespace(replacementLines.slice(start, start + len).join(""))
|
||||
const span = replacementLines.slice(start, start + len)
|
||||
if (span.some((line) => line.trim().length === 0)) continue
|
||||
const canonicalSpan = stripAllWhitespace(span.join(""))
|
||||
const original = canonicalToOriginal.get(canonicalSpan)
|
||||
if (original && original.count === 1 && canonicalSpan.length >= 6) {
|
||||
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
|
||||
@@ -159,6 +162,7 @@ export function restoreIndentForPairedReplacement(
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
const indent = leadingWhitespace(originalLines[idx])
|
||||
if (indent.length === 0) return line
|
||||
if (originalLines[idx].trim() === line.trim()) return line
|
||||
return `${indent}${line}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
||||
})
|
||||
|
||||
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/
|
||||
export const HASHLINE_LEGACY_REF_PATTERN = /^([0-9]+):([0-9a-fA-F]{2,})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
|
||||
|
||||
@@ -9,7 +9,7 @@ export function toHashlineContent(content: string): string {
|
||||
const hashlined = contentLines.map((line, i) => {
|
||||
const lineNum = i + 1
|
||||
const hash = computeLineHash(lineNum, line)
|
||||
return `${lineNum}#${hash}:${line}`
|
||||
return `${lineNum}#${hash}|${line}`
|
||||
})
|
||||
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
|
||||
}
|
||||
|
||||
@@ -6,23 +6,13 @@ function normalizeEditPayload(payload: string | string[]): string {
|
||||
}
|
||||
|
||||
function buildDedupeKey(edit: HashlineEdit): string {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "replace_lines":
|
||||
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_after":
|
||||
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_before":
|
||||
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||
case "insert_between":
|
||||
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
|
||||
return `replace|${edit.pos}|${edit.end ?? ""}|${normalizeEditPayload(edit.lines)}`
|
||||
case "append":
|
||||
return `append|${normalizeEditPayload(edit.text)}`
|
||||
return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
|
||||
case "prepend":
|
||||
return `prepend|${normalizeEditPayload(edit.text)}`
|
||||
return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
|
||||
default:
|
||||
return JSON.stringify(edit)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function applyReplaceLines(
|
||||
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||
const restored = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||
return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
@@ -80,7 +80,7 @@ export function applyInsertAfter(
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
@@ -97,38 +97,12 @@ export function applyInsertBefore(
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBetween(
|
||||
lines: string[],
|
||||
afterAnchor: string,
|
||||
beforeAnchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, afterAnchor)
|
||||
validateLineRef(lines, beforeAnchor)
|
||||
}
|
||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||
if (beforeLine <= afterLine) {
|
||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||
}
|
||||
result.splice(beforeLine - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
@@ -150,11 +124,3 @@ export function applyPrepend(lines: string[], text: string | string[]): string[]
|
||||
}
|
||||
return [...normalized, ...lines]
|
||||
}
|
||||
|
||||
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(`Text not found: "${oldText}"`)
|
||||
}
|
||||
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
||||
return content.replaceAll(oldText, replacement)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { applyHashlineEdits, applyInsertAfter, applyReplace, applyReplaceLines, applySetLine } from "./edit-operations"
|
||||
import { applyAppend, applyPrepend } from "./edit-operation-primitives"
|
||||
import { applyHashlineEdits } from "./edit-operations"
|
||||
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
@@ -49,31 +49,13 @@ describe("hashline edit operations", () => {
|
||||
//#when
|
||||
const result = applyHashlineEdits(
|
||||
lines.join("\n"),
|
||||
[{ type: "insert_before", line: anchorFor(lines, 2), text: "before 2" }]
|
||||
[{ op: "prepend", pos: anchorFor(lines, 2), lines: "before 2" }]
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("applies insert_between with dual anchors", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(
|
||||
lines.join("\n"),
|
||||
[{
|
||||
type: "insert_between",
|
||||
after_line: anchorFor(lines, 1),
|
||||
before_line: anchorFor(lines, 2),
|
||||
text: ["between"],
|
||||
}]
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nbetween\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("throws when insert_after receives empty text array", () => {
|
||||
//#given
|
||||
@@ -89,46 +71,18 @@ describe("hashline edit operations", () => {
|
||||
|
||||
//#when / #then
|
||||
expect(() =>
|
||||
applyHashlineEdits(lines.join("\n"), [{ type: "insert_before", line: anchorFor(lines, 1), text: [] }])
|
||||
applyHashlineEdits(lines.join("\n"), [{ op: "prepend", pos: anchorFor(lines, 1), lines: [] }])
|
||||
).toThrow(/non-empty/i)
|
||||
})
|
||||
|
||||
it("throws when insert_between receives empty text array", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2"]
|
||||
|
||||
//#when / #then
|
||||
expect(() =>
|
||||
applyHashlineEdits(
|
||||
lines.join("\n"),
|
||||
[{
|
||||
type: "insert_between",
|
||||
after_line: anchorFor(lines, 1),
|
||||
before_line: anchorFor(lines, 2),
|
||||
text: [],
|
||||
}]
|
||||
)
|
||||
).toThrow(/non-empty/i)
|
||||
})
|
||||
|
||||
it("applies replace operation", () => {
|
||||
//#given
|
||||
const content = "hello world foo"
|
||||
|
||||
//#when
|
||||
const result = applyReplace(content, "world", "universe")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("hello universe foo")
|
||||
})
|
||||
|
||||
it("applies mixed edits in one pass", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
|
||||
{ type: "set_line", line: anchorFor(lines, 3), text: "modified" },
|
||||
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
|
||||
{ op: "replace", pos: anchorFor(lines, 3), lines: "modified" },
|
||||
]
|
||||
|
||||
//#when
|
||||
@@ -138,13 +92,29 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
|
||||
})
|
||||
|
||||
it("applies replace before prepend when both target same line", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "prepend", pos: anchorFor(lines, 2), lines: "before line 2" },
|
||||
{ op: "replace", pos: anchorFor(lines, 2), lines: "modified line 2" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nbefore line 2\nmodified line 2\nline 3")
|
||||
})
|
||||
|
||||
it("deduplicates identical insert edits in one pass", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
|
||||
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
|
||||
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
|
||||
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
|
||||
]
|
||||
|
||||
//#when
|
||||
@@ -170,7 +140,7 @@ describe("hashline edit operations", () => {
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK:first\n2#NP:second")
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "first", "second", "line 3"])
|
||||
@@ -206,6 +176,28 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["if (x) {", " return 2", "}"])
|
||||
})
|
||||
|
||||
it("preserves intentional indentation removal (tab to no-tab)", () => {
|
||||
//#given
|
||||
const lines = ["# Title", "\t1절", "content"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1절")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["# Title", "1절", "content"])
|
||||
})
|
||||
|
||||
it("preserves intentional indentation removal (spaces to no-spaces)", () => {
|
||||
//#given
|
||||
const lines = ["function foo() {", " indented", "}"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "indented")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["function foo() {", "indented", "}"])
|
||||
})
|
||||
|
||||
it("strips boundary echo around replace_lines content", () => {
|
||||
//#given
|
||||
const lines = ["before", "old 1", "old 2", "after"]
|
||||
@@ -222,22 +214,6 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["before", "new 1", "new 2", "after"])
|
||||
})
|
||||
|
||||
it("throws when insert_between payload contains only boundary echoes", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when / #then
|
||||
expect(() =>
|
||||
applyHashlineEdits(lines.join("\n"), [
|
||||
{
|
||||
type: "insert_between",
|
||||
after_line: anchorFor(lines, 1),
|
||||
before_line: anchorFor(lines, 2),
|
||||
text: ["line 1", "line 2"],
|
||||
},
|
||||
])
|
||||
).toThrow(/non-empty/i)
|
||||
})
|
||||
|
||||
it("restores indentation for first replace_lines entry", () => {
|
||||
//#given
|
||||
@@ -250,6 +226,22 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
|
||||
})
|
||||
|
||||
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
|
||||
//#given — reproduces the 애국가 bug where blank+indented lines collapse
|
||||
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
|
||||
|
||||
//#when — replace the range with indented version (blank lines preserved)
|
||||
const result = applyReplaceLines(
|
||||
lines,
|
||||
anchorFor(lines, 1),
|
||||
anchorFor(lines, 7),
|
||||
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
|
||||
)
|
||||
|
||||
//#then — all 7 lines preserved with indentation, not collapsed to 3
|
||||
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
|
||||
})
|
||||
|
||||
it("collapses wrapped replacement span back to unique original single line", () => {
|
||||
//#given
|
||||
const lines = [
|
||||
@@ -322,8 +314,8 @@ describe("hashline edit operations", () => {
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, [
|
||||
{ type: "append", text: ["line 3"] },
|
||||
{ type: "prepend", text: ["line 0"] },
|
||||
{ op: "append", lines: ["line 3"] },
|
||||
{ op: "prepend", lines: ["line 0"] },
|
||||
])
|
||||
|
||||
//#then
|
||||
@@ -367,4 +359,33 @@ describe("hashline edit operations", () => {
|
||||
//#then
|
||||
expect(result).toEqual(["const a = 10;", "const b = 20;"])
|
||||
})
|
||||
|
||||
it("throws on overlapping range edits", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 3), lines: "replaced A" },
|
||||
{ op: "replace", pos: anchorFor(lines, 2), end: anchorFor(lines, 4), lines: "replaced B" },
|
||||
]
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/overlapping/i)
|
||||
})
|
||||
|
||||
it("allows non-overlapping range edits", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 2), lines: "replaced A" },
|
||||
{ op: "replace", pos: anchorFor(lines, 4), end: anchorFor(lines, 5), lines: "replaced B" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("replaced A\nline 3\nreplaced B")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { dedupeEdits } from "./edit-deduplication"
|
||||
import { collectLineRefs, getEditLineNumber } from "./edit-ordering"
|
||||
import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import {
|
||||
applyAppend,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyPrepend,
|
||||
applyReplace,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operation-primitives"
|
||||
@@ -29,46 +27,30 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
}
|
||||
|
||||
const dedupeResult = dedupeEdits(edits)
|
||||
const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
|
||||
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
|
||||
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
|
||||
const lineA = getEditLineNumber(a)
|
||||
const lineB = getEditLineNumber(b)
|
||||
if (lineB !== lineA) return lineB - lineA
|
||||
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
|
||||
})
|
||||
|
||||
let noopEdits = 0
|
||||
|
||||
let result = content
|
||||
let lines = result.length === 0 ? [] : result.split("\n")
|
||||
let lines = content.length === 0 ? [] : content.split("\n")
|
||||
|
||||
const refs = collectLineRefs(sortedEdits)
|
||||
validateLineRefs(lines, refs)
|
||||
|
||||
const overlapError = detectOverlappingRanges(sortedEdits)
|
||||
if (overlapError) throw new Error(overlapError)
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
switch (edit.type) {
|
||||
case "set_line": {
|
||||
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true })
|
||||
break
|
||||
}
|
||||
case "replace_lines": {
|
||||
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
|
||||
break
|
||||
}
|
||||
case "insert_after": {
|
||||
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "insert_before": {
|
||||
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "insert_between": {
|
||||
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
|
||||
switch (edit.op) {
|
||||
case "replace": {
|
||||
const next = edit.end
|
||||
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
|
||||
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -77,7 +59,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
break
|
||||
}
|
||||
case "append": {
|
||||
const next = applyAppend(lines, edit.text)
|
||||
const next = edit.pos
|
||||
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyAppend(lines, edit.lines)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -86,7 +70,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
break
|
||||
}
|
||||
case "prepend": {
|
||||
const next = applyPrepend(lines, edit.text)
|
||||
const next = edit.pos
|
||||
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyPrepend(lines, edit.lines)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
noopEdits += 1
|
||||
break
|
||||
@@ -94,17 +80,6 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
lines = next
|
||||
break
|
||||
}
|
||||
case "replace": {
|
||||
result = lines.join("\n")
|
||||
const replaced = applyReplace(result, edit.old_text, edit.new_text)
|
||||
if (replaced === result) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
result = replaced
|
||||
lines = result.split("\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,12 +93,3 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||
return applyHashlineEditsWithReport(content, edits).content
|
||||
}
|
||||
|
||||
export {
|
||||
applySetLine,
|
||||
applyReplaceLines,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyReplace,
|
||||
} from "./edit-operation-primitives"
|
||||
|
||||
@@ -2,23 +2,13 @@ import { parseLineRef } from "./validation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
export function getEditLineNumber(edit: HashlineEdit): number {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return parseLineRef(edit.line).line
|
||||
case "replace_lines":
|
||||
return parseLineRef(edit.end_line).line
|
||||
case "insert_after":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_before":
|
||||
return parseLineRef(edit.line).line
|
||||
case "insert_between":
|
||||
return parseLineRef(edit.before_line).line
|
||||
case "append":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
case "prepend":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return Number.NEGATIVE_INFINITY
|
||||
return parseLineRef(edit.end ?? edit.pos).line
|
||||
case "append":
|
||||
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
|
||||
case "prepend":
|
||||
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
@@ -26,23 +16,41 @@ export function getEditLineNumber(edit: HashlineEdit): number {
|
||||
|
||||
export function collectLineRefs(edits: HashlineEdit[]): string[] {
|
||||
return edits.flatMap((edit) => {
|
||||
switch (edit.type) {
|
||||
case "set_line":
|
||||
return [edit.line]
|
||||
case "replace_lines":
|
||||
return [edit.start_line, edit.end_line]
|
||||
case "insert_after":
|
||||
return [edit.line]
|
||||
case "insert_before":
|
||||
return [edit.line]
|
||||
case "insert_between":
|
||||
return [edit.after_line, edit.before_line]
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return edit.end ? [edit.pos, edit.end] : [edit.pos]
|
||||
case "append":
|
||||
case "prepend":
|
||||
case "replace":
|
||||
return []
|
||||
return edit.pos ? [edit.pos] : []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
|
||||
const ranges: { start: number; end: number; idx: number }[] = []
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i]
|
||||
if (edit.op !== "replace" || !edit.end) continue
|
||||
const start = parseLineRef(edit.pos).line
|
||||
const end = parseLineRef(edit.end).line
|
||||
ranges.push({ start, end, idx: i })
|
||||
}
|
||||
if (ranges.length < 2) return null
|
||||
|
||||
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
const prev = ranges[i - 1]
|
||||
const curr = ranges[i]
|
||||
if (curr.start <= prev.end) {
|
||||
return (
|
||||
`Overlapping range edits detected: ` +
|
||||
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
|
||||
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
|
||||
`Use pos-only replace for single-line edits.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[A-Z]{2}:/
|
||||
const DIFF_PLUS_RE = /^[+-](?![+-])/
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/
|
||||
|
||||
function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
@@ -7,6 +7,7 @@ function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
@@ -53,6 +54,7 @@ export function restoreLeadingIndent(templateLine: string, line: string): string
|
||||
const templateIndent = leadingWhitespace(templateLine)
|
||||
if (templateIndent.length === 0) return line
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
if (templateLine.trim() === line.trim()) return line
|
||||
return `${templateIndent}${line}`
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user