Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
f48907ae2e feat(agents): add Gemini-optimized prompts for Sisyphus, Sisyphus-Junior, Prometheus, Atlas
Gemini models are aggressively optimistic and avoid tool calls in favor of
internal reasoning. These prompts counter that with:
- TOOL_CALL_MANDATE sections forcing actual tool usage
- Anti-optimism checkpoints before claiming completion
- Stronger delegation enforcement (Gemini prefers doing work itself)
- Aggressive verification language (subagent results are 'EXTREMELY SUSPICIOUS')
- Mandatory thinking checkpoints in Prometheus (prevents jumping to conclusions)
- Scope discipline reminders (creativity → implementation quality, not scope creep)
2026-02-22 15:08:24 +09:00
137 changed files with 2961 additions and 4157 deletions

View File

@@ -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) もありません。

View File

@@ -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)도 없습니다.

View File

@@ -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.

View File

@@ -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 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。

View File

@@ -82,9 +82,6 @@
"hashline_edit": {
"type": "boolean"
},
"model_fallback": {
"type": "boolean"
},
"agents": {
"type": "object",
"properties": {
@@ -291,18 +288,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -510,18 +495,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -729,18 +702,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -948,18 +909,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1167,18 +1116,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1386,18 +1323,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1605,18 +1530,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1824,18 +1737,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2043,18 +1944,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2262,18 +2151,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2481,18 +2358,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2700,18 +2565,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2919,18 +2772,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -3138,18 +2979,6 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1",
"oh-my-opencode-darwin-arm64": "3.7.4",
"oh-my-opencode-darwin-x64": "3.7.4",
"oh-my-opencode-linux-arm64": "3.7.4",
"oh-my-opencode-linux-arm64-musl": "3.7.4",
"oh-my-opencode-linux-x64": "3.7.4",
"oh-my-opencode-linux-x64-musl": "3.7.4",
"oh-my-opencode-windows-x64": "3.7.4",
},
},
},
@@ -228,19 +228,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.4",
"version": "3.8.1",
"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.4",
"oh-my-opencode-darwin-x64": "3.8.4",
"oh-my-opencode-linux-arm64": "3.8.4",
"oh-my-opencode-linux-arm64-musl": "3.8.4",
"oh-my-opencode-linux-x64": "3.8.4",
"oh-my-opencode-linux-x64-musl": "3.8.4",
"oh-my-opencode-windows-x64": "3.8.4"
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.4",
"version": "3.8.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1679,30 +1679,6 @@
"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
}
]
}

View File

@@ -6,13 +6,12 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
@@ -21,7 +20,6 @@ import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
import { getGeminiAtlasPrompt } from "./gemini"
import {
getCategoryDescription,
buildAgentSelectionSection,
@@ -32,7 +30,7 @@ import {
const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt" | "gemini"
export type AtlasPromptSource = "default" | "gpt"
/**
* Determines which Atlas prompt to use based on model.
@@ -41,9 +39,6 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -63,8 +58,6 @@ export function getAtlasPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "gemini":
return getGeminiAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()

View File

@@ -1,2 +1,14 @@
export { createAtlasAgent, atlasPromptMetadata } from "./agent"
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export { isGptModel } from "../types"

View File

@@ -1,4 +1,28 @@
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"

View File

@@ -2,5 +2,15 @@ export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
getPrometheusPrompt,
getPrometheusPromptSource,
} from "./system-prompt"
export type { PrometheusPromptSource } from "./system-prompt"
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
export { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
export { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
export { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
export { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
export { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"

View File

@@ -5,8 +5,7 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
import { getGptPrometheusPrompt } from "./gpt"
import { getGeminiPrometheusPrompt } from "./gemini"
import { isGptModel, isGeminiModel } from "../types"
import { isGptModel } from "../types"
/**
* Combined Prometheus system prompt (Claude-optimized, default).
@@ -31,7 +30,7 @@ export const PROMETHEUS_PERMISSION = {
question: "allow" as const,
}
export type PrometheusPromptSource = "default" | "gpt" | "gemini"
export type PrometheusPromptSource = "default" | "gpt"
/**
* Determines which Prometheus prompt to use based on model.
@@ -40,16 +39,12 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
/**
* Gets the appropriate Prometheus prompt based on model.
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
*/
export function getPrometheusPrompt(model?: string): string {
@@ -58,8 +53,6 @@ export function getPrometheusPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptPrometheusPrompt()
case "gemini":
return getGeminiPrometheusPrompt()
case "default":
default:
return PROMETHEUS_SYSTEM_PROMPT

View File

@@ -6,8 +6,6 @@
* - 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.
@@ -79,39 +77,3 @@ 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>`;
}

View File

@@ -6,13 +6,12 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
@@ -21,7 +20,6 @@ import {
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
const MODE: AgentMode = "subagent"
@@ -34,7 +32,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini"
export type SisyphusJuniorPromptSource = "default" | "gpt"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
@@ -43,9 +41,6 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -62,8 +57,6 @@ export function buildSisyphusJuniorPrompt(
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "gemini":
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)

View File

@@ -1,6 +1,5 @@
export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
export {
SISYPHUS_JUNIOR_DEFAULTS,

View File

@@ -1,12 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "./types";
import { isGptModel, isGeminiModel } from "./types";
import {
buildGeminiToolMandate,
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
buildGeminiIntentGateEnforcement,
} from "./sisyphus-gemini-overlays";
import { isGptModel } from "./types";
const MODE: AgentMode = "primary";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
@@ -336,11 +330,12 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
\`\`\`
### Background Result Collection:
1. Launch parallel agents \u2192 receive task_ids
2. Continue immediate work (explore, librarian results)
1. Launch parallel agents receive task_ids
2. Continue immediate work
3. When results needed: \`background_output(task_id="...")\`
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)\`.
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.
### Search Stop Conditions
@@ -477,9 +472,9 @@ If verification fails:
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
### Before Delivering Final Answer:
- **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)\`.**
- 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.
</Behavior_Instructions>
${oracleSection}
@@ -553,7 +548,7 @@ export function createSisyphusAgent(
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? [];
const categories = availableCategories ?? [];
let prompt = availableAgents
const prompt = availableAgents
? buildDynamicSisyphusPrompt(
model,
availableAgents,
@@ -564,15 +559,6 @@ export function createSisyphusAgent(
)
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
if (isGeminiModel(model)) {
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();
}
const permission = {
question: "allow",
call_omo_agent: "deny",

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel } from "./types";
import { isGptModel } from "./types";
describe("isGptModel", () => {
test("standard openai provider models", () => {
@@ -47,47 +47,3 @@ describe("isGptModel", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
});
});
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
});
test("#given google-vertex provider models #then returns true", () => {
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
});
test("#given github copilot gemini models #then returns true", () => {
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
});
test("#given litellm proxied gemini models #then returns true", () => {
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
});
test("#given other proxied gemini models #then returns true", () => {
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});
test("#given gpt models #then returns false", () => {
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
expect(isGeminiModel("openai/o3-mini")).toBe(false);
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
});
test("#given claude models #then returns false", () => {
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
});
test("#given opencode provider #then returns false", () => {
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
});
});

View File

@@ -80,19 +80,6 @@ export function isGptModel(model: string): boolean {
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
}
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
export function isGeminiModel(model: string): boolean {
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
return true
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
return true
const modelName = extractModelName(model).toLowerCase()
return modelName.startsWith("gemini-")
}
export type BuiltinAgentName =
| "sisyphus"
| "hephaestus"

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "bun:test"
import { stripAnsi } from "./format-shared"
import { afterEach, describe, expect, it, mock } from "bun:test"
import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult {
@@ -40,122 +39,78 @@ function createDoctorResult(): DoctorResult {
}
}
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("formatter", () => {
afterEach(() => {
mock.restore()
})
describe("#given status mode", () => {
it("renders system version line", async () => {
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
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()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "status"))
const output = formatDoctorOutput(createDoctorResult(), "default")
//#then
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
})
it("renders tool and MCP info", async () => {
it("dispatches to status formatter for status mode", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
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()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "status"))
const output = formatDoctorOutput(createDoctorResult(), "status")
//#then
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")
expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
})
it("shows check summary counts", async () => {
it("dispatches to verbose formatter for verbose mode", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
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()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
const output = formatDoctorOutput(createDoctorResult(), "verbose")
//#then
expect(output).toContain("1 passed")
expect(output).toContain("0 failed")
expect(output).toContain("1 warnings")
expect(output).toBe("verbose-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON", async () => {
it("returns valid JSON payload", 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)
@@ -164,6 +119,7 @@ describe("formatDoctorOutput", () => {
//#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)
})
})

View File

@@ -31,7 +31,7 @@ export async function resolveSession(options: {
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as Record<string, unknown>,
} as any,
query: { directory },
})

View File

@@ -1,5 +1,18 @@
export {
OhMyOpenCodeConfigSchema,
AgentOverrideConfigSchema,
AgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
RuntimeFallbackConfigSchema,
FallbackModelsSchema,
} from "./schema"
export type {

View File

@@ -47,12 +47,6 @@ 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({

View File

@@ -35,8 +35,6 @@ 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(),

View File

@@ -0,0 +1,40 @@
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
}

View File

@@ -0,0 +1,14 @@
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`
}

View File

@@ -1,2 +1,5 @@
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"

View File

@@ -25,6 +25,7 @@ import {
hasMoreFallbacks,
} from "../../shared/model-error-classifier"
import {
MIN_IDLE_TIME_MS,
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
} from "./constants"
@@ -42,7 +43,6 @@ import {
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
@@ -268,7 +268,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
} as Record<string, unknown>,
} as any,
query: {
directory: parentDirectory,
},
@@ -740,15 +740,61 @@ export class BackgroundManager {
}
if (event.type === "session.idle") {
if (!props || typeof props !== "object") return
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers,
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id),
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!this.idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
const timer = setTimeout(() => {
this.idleDeferralTimers.delete(task.id)
this.handleEvent({ type: "session.idle", properties: { sessionID } })
}, remainingMs)
this.idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
// Re-check status after async operation (could have been completed by polling)
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
// Re-check status after async operation again
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await this.tryCompleteTask(task, "session.idle event")
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
})
}

View File

@@ -0,0 +1 @@
export { getMessageDir } from "../../shared"

View File

@@ -0,0 +1,81 @@
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) }
}

View File

@@ -0,0 +1,39 @@
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")
})
})

View File

@@ -0,0 +1,103 @@
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)
}
}

View File

@@ -0,0 +1,9 @@
import type { OpencodeClient } from "./constants"
import type { ConcurrencyManager } from "./concurrency"
import type { TaskStateManager } from "./state"
export interface ResultHandlerContext {
client: OpencodeClient
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}

View File

@@ -0,0 +1,7 @@
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"

View File

@@ -1,340 +0,0 @@
import { describe, it, expect, mock } from "bun:test"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import type { BackgroundTask } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-idle-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test idle handler",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
...overrides,
}
}
describe("handleSessionIdleBackgroundEvent", () => {
describe("#given no sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: {},
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given non-string sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: 123 },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given no task found for session", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: "ses-unknown" },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task is not running", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ status: "completed" })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task has no startedAt", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ startedAt: undefined })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
it("#when idle fires early #then should defer with timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.has(task.id)).toBe(true)
expect(emitIdleEvent).not.toHaveBeenCalled()
} finally {
clearTimeout(idleDeferralTimers.get(task.id)!)
Date.now = realDateNow
}
})
it("#when idle already deferred #then should not create duplicate timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const existingTimer = setTimeout(() => {}, 99999)
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
[task.id, existingTimer],
])
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
} finally {
clearTimeout(existingTimer)
Date.now = realDateNow
}
})
it("#when deferred timer fires #then should emit idle event", async () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
const remainingMs = 50
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then - wait for deferred timer
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
expect(idleDeferralTimers.has(task.id)).toBe(false)
} finally {
Date.now = realDateNow
}
})
})
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
})
it("#when session has no valid output #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(false),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task has incomplete todos #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(true),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during validation #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: async () => {
task.status = "completed"
return true
},
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during todo check #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: async () => {
task.status = "cancelled"
return false
},
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,89 @@
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
}
}

View File

@@ -0,0 +1,46 @@
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)
}
}

View File

@@ -0,0 +1,33 @@
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
}
}

View File

@@ -61,7 +61,9 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
} as Record<string, unknown>,
title: `Background: ${input.description}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
query: {
directory: parentDirectory,
},

View File

@@ -0,0 +1,45 @@
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
}

View File

@@ -0,0 +1,7 @@
import type { LaunchInput } from "../types"
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
return input.model
? `${input.model.providerID}/${input.model.modelID}`
: input.agent
}

View File

@@ -0,0 +1,12 @@
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
}

View File

@@ -0,0 +1,40 @@
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)
}

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"

View File

@@ -0,0 +1,3 @@
export * from "./schema"
export * from "./oauth-authorization-flow"
export * from "./provider"

View File

@@ -4,12 +4,6 @@ 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
@@ -176,7 +170,8 @@ export class TaskToastManager {
* Show consolidated toast with all running/queued tasks
*/
private showTaskListToast(newTask: TrackedTask): void {
const tuiClient = this.client as ClientWithTui
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return
const message = this.buildTaskListMessage(newTask)
@@ -201,7 +196,8 @@ export class TaskToastManager {
* Show task completion toast
*/
showCompletionToast(task: { id: string; description: string; duration: string }): void {
const tuiClient = this.client as ClientWithTui
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return
this.removeTask(task.id)

View File

@@ -1,5 +1,4 @@
import type { AutoCompactState } from "./types";
import type { OhMyOpenCodeConfig } from "../../config";
import type { ExperimentalConfig } from "../../config";
import { TRUNCATE_CONFIG } from "./types";
@@ -16,15 +15,15 @@ export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
client: Client,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string,
pluginConfig: OhMyOpenCodeConfig,
_experimental?: ExperimentalConfig
experimental?: ExperimentalConfig,
): Promise<void> {
void _experimental
void experimental
if (autoCompactState.compactionInProgress.has(sessionID)) {
await client.tui
await (client as Client).tui
.showToast({
body: {
title: "Compact In Progress",
@@ -56,7 +55,7 @@ export async function executeCompact(
const result = await runAggressiveTruncationStrategy({
sessionID,
autoCompactState,
client: client,
client: client as Client,
directory,
truncateAttempt: truncateState.truncateAttempt,
currentTokens: errorData.currentTokens,
@@ -71,9 +70,8 @@ export async function executeCompact(
sessionID,
msg,
autoCompactState,
client: client,
client: client as Client,
directory,
pluginConfig,
errorType: errorData?.errorType,
messageIndex: errorData?.messageIndex,
})

View File

@@ -1,7 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Client } from "./client"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
import type { ExperimentalConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
@@ -9,7 +8,6 @@ import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig
pluginConfig: OhMyOpenCodeConfig
}
function createRecoveryState(): AutoCompactState {
@@ -30,7 +28,6 @@ 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 } }) => {
@@ -92,9 +89,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client as Client,
ctx.client,
ctx.directory,
pluginConfig,
experimental,
)
}, 300)
@@ -160,9 +156,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client as Client,
ctx.client,
ctx.directory,
pluginConfig,
experimental,
)
}

View File

@@ -1,19 +1,16 @@
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> {
@@ -77,14 +74,7 @@ export async function runSummarizeRetryStrategy(params: {
})
.catch(() => {})
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
params.pluginConfig,
params.sessionID,
providerID,
modelID
)
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
const summarizeBody = { providerID, modelID, auto: true }
await params.client.session.summarize({
path: { id: params.sessionID },
body: summarizeBody as never,

View File

@@ -104,65 +104,6 @@ ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CER
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
They ROUTINELY:
- Ship code with syntax errors they didn't bother to check
- Create stub implementations with TODOs and call it "done"
- Write tests that pass trivially (testing nothing meaningful)
- Implement logic that does NOT match what was requested
- Add features nobody asked for and call it "improvement"
- Report "all tests pass" when they didn't run any tests
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
---
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` — see exactly which files changed.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file:
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
- Scope creep? Did the subagent add things NOT in the task spec?
4. Cross-check EVERY claim against actual code.
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
**PHASE 2: RUN AUTOMATED CHECKS**
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
3. Build/typecheck — exit 0.
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
- **Frontend/UI**: \`/playwright\`
- **TUI/CLI**: \`interactive_bash\`
- **API/Backend**: \`Bash\` with curl
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
**PHASE 4: GATE DECISION**
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
3. Am I confident nothing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
**DO NOT proceed to the next task until all 4 phases are complete.**`
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
---

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { afterEach, describe, it, expect, 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<(directory: string) => string | null>(() => null)
const mockGetLocalDevVersion = mock(() => "3.6.0")
mock.module("./hook/config-errors-toast", () => ({
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
@@ -40,49 +40,31 @@ mock.module("../../shared/logger", () => ({
log: () => {},
}))
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)
})
const { createAutoUpdateCheckerHook } = await import("./hook")
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"
const createAutoUpdateCheckerHook = await importFreshHookFactory()
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
showStartupToast: true,
isSisyphusEnabled: true,
autoUpdate: true,
})
const hook = createAutoUpdateCheckerHook(
{
directory: "/test",
client: {} as never,
} as never,
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
)
//#when - session.created event arrives
hook.event({
@@ -91,7 +73,7 @@ describe("createAutoUpdateCheckerHook", () => {
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await new Promise((resolve) => setTimeout(resolve, 25))
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -100,144 +82,6 @@ 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")
)
})
})

View File

@@ -1,208 +1,177 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { describe, it, expect, mock, beforeEach } from "bun:test"
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")
// 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)
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockRunBunInstall = mock(async () => true)
const mockShowUpdateAvailableToast = mock(
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
)
const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
)
const mockShowUpdateAvailableToast = mock(async () => {})
const mockShowAutoUpdatedToast = mock(async () => {})
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: () => {} }))
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
const mockCtx = { directory: "/test" } as any
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
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 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()
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)
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
it("#then should NOT invalidate package cache", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).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 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 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")
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(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockUpdatePinnedVersion).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()
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()
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()
})
})
})

View File

@@ -1,204 +1,161 @@
import { randomUUID } from "node:crypto"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
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 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,
}),
}
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>) => {})
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(() => {
storageMaps.clear()
findAgentsMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
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")
testRoot = join(
tmpdir(),
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})
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: {} }
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")
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
// then
expect(output.output).toContain("[Directory Context:")
expect(output.output).toContain(srcAgentsContent)
})
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
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(testRoot, "file.ts"),
sessionID: "session-root-skip",
output,
})
// then
expect(output.output).not.toContain(rootAgentsContent)
expect(output.output).not.toContain("[Directory Context:")
})
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
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multiple",
output,
})
// 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,
}),
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
// when
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator: truncatedTruncator,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-truncated",
output,
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
// then
expect(output.output).toContain("truncated...")
expect(output.output).toContain("[Note: Content was truncated")
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
})
it("does nothing when filePath cannot be resolved", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
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")
// when
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,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "",
sessionID: "session-empty-path",
output,
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
// then
expect(output.output).toBe("base 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)
})
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,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
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: {} },
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
})
})

View File

@@ -1,212 +1,161 @@
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"
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 }),
}
}
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>) => {})
describe("processFilePathForReadmeInjection", () => {
let testRoot = ""
beforeEach(() => {
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
findReadmeMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
testRoot = join(
tmpdir(),
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
storageMaps.clear()
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
storageMaps.clear()
})
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")
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,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "base", metadata: {} }
const truncator = createTruncator()
// when
//#when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Source README")
expect(output.output).toContain("local context")
})
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
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(testRoot, "file.ts"),
sessionID: "session-root",
output,
})
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Root README")
expect(output.output).toContain("root context")
})
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
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multi",
output,
})
// 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"),
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: firstOutput,
output: { title: "Result", output: "", metadata: {} },
})
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
})
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,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
//#when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches,
filePath: join(sourceDirectory, "b.ts"),
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: secondOutput,
output: { title: "Result", output: "", metadata: {} },
})
// then
expect(countReadmeMarkers(firstOutput.output)).toBe(1)
expect(secondOutput.output).toBe("")
//#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)
})
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")
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,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator({ result: "trimmed content", truncated: true })
// when
//#when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-truncated",
output,
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: {} },
})
// 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")
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
})
})

View File

@@ -0,0 +1,306 @@
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(() => {})
})
})
})

View File

@@ -0,0 +1 @@
export { createHashlineEditDiffEnhancerHook } from "./hook"

View File

@@ -1,19 +1,14 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
const WRITE_SUCCESS_MARKER = "File written successfully."
import { toHashlineContent } from "../../tools/hashline-edit/diff-utils"
interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean }
}
const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/
const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/
const READ_LINE_PATTERN = /^(\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"
@@ -29,39 +24,18 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
function isTextFile(output: string): boolean {
const firstLine = output.split("\n")[0] ?? ""
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
return READ_LINE_PATTERN.test(firstLine)
}
function transformLine(line: string): string {
const parsed = parseReadLine(line)
if (!parsed) {
const match = READ_LINE_PATTERN.exec(line)
if (!match) {
return line
}
if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) {
return line
}
const hash = computeLineHash(parsed.lineNumber, parsed.content)
return `${parsed.lineNumber}#${hash}|${parsed.content}`
const lineNumber = parseInt(match[1], 10)
const content = match[2]
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}`
}
function transformOutput(output: string): string {
@@ -70,43 +44,25 @@ function transformOutput(output: string): string {
}
const lines = output.split("\n")
const contentStart = lines.findIndex(
(line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG)
)
const contentStart = lines.indexOf(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)
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 (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) {
const fileLines = lines.slice(contentStart + 1, contentEnd)
if (!isTextFile(fileLines[0] ?? "")) {
return output
}
const result: string[] = []
for (const line of fileLines) {
if (!parseReadLine(line)) {
if (!READ_LINE_PATTERN.test(line)) {
result.push(...fileLines.slice(result.length))
break
}
result.push(transformLine(line))
}
const prefixLines = inlineFirst !== null
? [...lines.slice(0, blockStart), openTag]
: lines.slice(0, blockStart + 1)
return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n")
return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n")
}
if (!isTextFile(lines[0] ?? "")) {
@@ -115,7 +71,7 @@ function transformOutput(output: string): string {
const result: string[] = []
for (const line of lines) {
if (!parseReadLine(line)) {
if (!READ_LINE_PATTERN.test(line)) {
result.push(...lines.slice(result.length))
break
}
@@ -142,12 +98,7 @@ function extractFilePath(metadata: unknown): string | undefined {
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
return
}
const outputLower = output.output.toLowerCase()
if (outputLower.startsWith("error") || outputLower.includes("failed")) {
if (output.output.includes("Updated file (LINE#ID:content):")) {
return
}
@@ -162,8 +113,8 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
}
const content = await file.text()
const lineCount = content === "" ? 0 : content.split("\n").length
output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.`
const hashlined = toHashlineContent(content)
output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}`
}
export function createHashlineReadEnhancerHook(

View File

@@ -1,5 +1,3 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createHashlineReadEnhancerHook } from "./hook"
@@ -47,70 +45,11 @@ 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 } })
@@ -132,66 +71,13 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/)
expect(lines[4]).toBe("(End of file - total 3 lines)")
})
it("hashifies read output with <file> and zero-padded pipe format", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
"00001| const x = 1",
"00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[5]).toBe("</file>")
})
it("hashifies pipe format even with leading spaces", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
" 00001| const x = 1",
" 00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends simple summary for write tool instead of full hashlined content", async () => {
it("appends LINE#ID output for write tool using metadata filepath", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-"))
@@ -208,55 +94,9 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output)
//#then
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.")
expect(output.output).toContain("Updated file (LINE#ID:content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/)
fs.rmSync(tempDir, { recursive: true, force: true })
})

View File

@@ -1,265 +0,0 @@
/**
* 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
}

View File

@@ -4,22 +4,19 @@
* Routing:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Gemini models → gemini.ts
* 4. Default (Claude, etc.) → default.ts (optimized for Claude series)
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
*/
export { isPlannerAgent, isGptModel, isGeminiModel, getUltraworkSource } from "./source-detector"
export { isPlannerAgent, isGptModel, 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.
@@ -32,8 +29,6 @@ export function getUltraworkMessage(agentName?: string, modelID?: string): strin
return getPlannerUltraworkMessage()
case "gpt":
return getGptUltraworkMessage()
case "gemini":
return getGeminiUltraworkMessage()
case "default":
default:
return getDefaultUltraworkMessage()

View File

@@ -4,11 +4,10 @@
* Routing logic:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Gemini models → gemini.ts
* 4. Everything else (Claude, etc.) → default.ts
* 3. Everything else (Claude, etc.) → default.ts
*/
import { isGptModel, isGeminiModel } from "../../../agents/types"
import { isGptModel } from "../../../agents/types"
/**
* Checks if agent is a planner-type agent.
@@ -23,10 +22,10 @@ export function isPlannerAgent(agentName?: string): boolean {
return /\bplan\b/.test(normalized)
}
export { isGptModel, isGeminiModel }
export { isGptModel }
/** Ultrawork message source type */
export type UltraworkSource = "planner" | "gpt" | "gemini" | "default"
export type UltraworkSource = "planner" | "gpt" | "default"
/**
* Determines which ultrawork message source to use.
@@ -45,11 +44,6 @@ export function getUltraworkSource(
return "gpt"
}
// Priority 3: Gemini models
if (modelID && isGeminiModel(modelID)) {
return "gemini"
}
// Default: Claude and other models
return "default"
}

View File

@@ -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"

View File

@@ -1,7 +1,5 @@
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 = {
@@ -53,7 +51,6 @@ type PluginInput = {
export function createPreemptiveCompactionHook(
ctx: PluginInput,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ModelCacheStateLike,
) {
const compactionInProgress = new Set<string>()
@@ -87,16 +84,9 @@ 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: targetProviderID, modelID: targetModelID, auto: true } as never,
body: { providerID: cached.providerID, modelID, auto: true } as never,
query: { directory: ctx.directory },
})

View File

@@ -1,93 +0,0 @@
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
const { createSessionNotification } = require("./session-notification")
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
const utils = require("./session-notification-utils")
describe("session-notification input-needed events", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
}
beforeEach(() => {
_resetForTesting()
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
})
afterEach(() => {
subagentSessions.clear()
_resetForTesting()
})
test("sends question notification when question tool asks for input", async () => {
const sessionID = "main-question"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: "question",
args: {
questions: [
{
question: "Which branch should we use?",
options: [{ label: "main" }, { label: "dev" }],
},
],
},
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent is asking a question")
})
test("sends permission notification for permission events", async () => {
const sessionID = "main-permission"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "permission.asked",
properties: {
sessionID,
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
})
export {}

View File

@@ -15,8 +15,6 @@ import { createIdleNotificationScheduler } from "./session-notification-schedule
interface SessionNotificationConfig {
title?: string
message?: string
questionMessage?: string
permissionMessage?: string
playSound?: boolean
soundPath?: string
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
@@ -38,8 +36,6 @@ export function createSessionNotification(
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: defaultSoundPath,
idleConfirmationDelay: 1500,
@@ -57,56 +53,6 @@ export function createSessionNotification(
playSound: playSessionNotificationSound,
})
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return false
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
@@ -122,10 +68,14 @@ export function createSessionNotification(
}
if (event.type === "session.idle") {
const sessionID = getSessionID(props)
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (!shouldNotifyForSession(sessionID)) return
if (subagentSessions.has(sessionID)) return
// Only trigger notifications for the main session (not subagent sessions)
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return
scheduler.scheduleIdleNotification(sessionID)
return
@@ -133,47 +83,17 @@ export function createSessionNotification(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = getSessionID({ ...props, info })
const sessionID = info?.sessionID as string | undefined
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = getSessionID(props)
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
scheduler.markSessionActivity(sessionID)
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText)
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
}
}
}
return
}

View File

@@ -0,0 +1,87 @@
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
}

View File

@@ -5,12 +5,6 @@ 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"
@@ -83,7 +77,8 @@ export async function recoverToolResultMissing(
}
try {
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
// @ts-expect-error - SDK types may not include tool_result parts
await client.session.promptAsync(promptInput)
return true
} catch {

View File

@@ -1,34 +0,0 @@
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("/"),
}
}

View File

@@ -21,18 +21,6 @@ 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>,
@@ -103,7 +91,7 @@ function findPreviousThinkingContent(
for (const part of msg.parts) {
const type = part.type as string
if (type === "thinking" || type === "reasoning") {
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
const thinking = (part as any).thinking || (part as any).text
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
return thinking
}
@@ -126,7 +114,7 @@ function prependThinkingBlock(message: MessageWithParts, thinkingContent: string
const thinkingPart = {
type: "thinking" as const,
id: `prt_0000000000_synthetic_thinking`,
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
sessionID: (message.info as any).sessionID || "",
messageID: message.info.id,
thinking: thinkingContent,
synthetic: true,
@@ -150,7 +138,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 unknown as MessageInfoExtended)?.modelID || ""
const modelID = (lastUserMessage?.info as any)?.modelID || ""
// Only process if extended thinking might be enabled
if (!isExtendedThinkingModel(modelID)) {

View File

@@ -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 } from "path"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } 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 BLOCK_MESSAGE = "File already exists. Use edit tool instead."
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -37,8 +37,6 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
@@ -75,6 +73,7 @@ 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())
@@ -175,7 +174,16 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
if (!isInsideSessionDirectory) {
return
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)
}
if (toolName === "read") {
@@ -198,7 +206,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return
}
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,

View File

@@ -7,6 +7,7 @@ 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>
@@ -338,7 +339,7 @@ describe("createWriteExistingFileGuardHook", () => {
).resolves.toBeDefined()
})
test("#given existing file outside session directory #when write executes #then allows", async () => {
test("#given existing file outside session directory #when write executes #then blocks", async () => {
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
try {
@@ -348,9 +349,9 @@ describe("createWriteExistingFileGuardHook", () => {
await expect(
invoke({
tool: "write",
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
})
).resolves.toBeDefined()
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
} finally {
rmSync(outsideDir, { recursive: true, force: true })
}

View File

@@ -1161,6 +1161,8 @@ 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"),
])
@@ -1204,6 +1206,10 @@ 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 () => {

View File

@@ -1,83 +0,0 @@
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()
})
})
})
})

View File

@@ -84,7 +84,6 @@ export function applyToolConfig(params: {
question: questionPermission,
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}
const junior = agentByKey(params.agentResult, "sisyphus-junior");
@@ -94,7 +93,6 @@ export function applyToolConfig(params: {
task: "allow",
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}

View File

@@ -53,8 +53,7 @@ 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 modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
const { handler, abortCalls, promptCalls } = createHandler()
//#when
await handler({
@@ -96,8 +95,7 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_main_fallback_nested"
setMainSession(sessionID)
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
const { handler, abortCalls, promptCalls } = createHandler()
//#when
await handler({
@@ -342,64 +340,4 @@ 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([])
})
})

View File

@@ -1,58 +1,53 @@
import type { OhMyOpenCodeConfig } from "../config";
import type { PluginContext } from "./types";
import type { OhMyOpenCodeConfig } from "../config"
import type { PluginContext } from "./types"
import {
clearSessionAgent,
getMainSessionID,
getSessionAgent,
setMainSession,
subagentSessions,
syncSubagentSessions,
setMainSession,
updateSessionAgent,
} from "../features/claude-code-session-state";
import {
clearPendingModelFallback,
clearSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { resetMessageCursor } from "../shared";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
} from "../features/claude-code-session-state"
import { resetMessageCursor } from "../shared"
import { lspManager } from "../tools"
import { shouldRetryError } from "../shared/model-error-classifier"
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
import { log } from "../shared/logger"
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers";
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
import type { CreatedHooks } from "../create-hooks"
import type { Managers } from "../create-managers"
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"
type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
clear: (sessionID: string) => void;
};
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
clear: (sessionID: string) => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
return typeof value === "object" && value !== null
}
function normalizeFallbackModelID(modelID: string): string {
return modelID
.replace(/-thinking$/i, "")
.replace(/-max$/i, "")
.replace(/-high$/i, "");
.replace(/-high$/i, "")
}
function extractErrorName(error: unknown): string | undefined {
if (isRecord(error) && typeof error.name === "string") return error.name;
if (error instanceof Error) return error.name;
return undefined;
if (isRecord(error) && typeof error.name === "string") return error.name
if (error instanceof Error) return error.name
return undefined
}
function extractErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (!error) return ""
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (isRecord(error)) {
const candidates: unknown[] = [
@@ -61,115 +56,116 @@ function extractErrorMessage(error: unknown): string {
error.error,
isRecord(error.data) ? error.data.error : undefined,
error.cause,
];
]
for (const candidate of candidates) {
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
return candidate.message;
return candidate.message
}
}
}
try {
return JSON.stringify(error);
return JSON.stringify(error)
} catch {
return String(error);
return String(error)
}
}
function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase();
function extractProviderModelFromErrorMessage(
message: string,
): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase()
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i);
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
if (providerModel) {
return {
providerID: providerModel[1],
modelID: providerModel[2],
};
}
}
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i);
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
if (modelOnly) {
return {
modelID: modelOnly[1],
};
}
}
return {};
return {}
}
type EventInput = Parameters<NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>>[0];
type EventInput = Parameters<
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
>[0]
export function createEventHandler(args: {
ctx: PluginContext;
pluginConfig: OhMyOpenCodeConfig;
firstMessageVariantGate: FirstMessageVariantGate;
managers: Managers;
hooks: CreatedHooks;
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
firstMessageVariantGate: FirstMessageVariantGate
managers: Managers
hooks: CreatedHooks
}): (input: EventInput) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args;
const { ctx, firstMessageVariantGate, managers, hooks } = args
const pluginContext = ctx as {
directory: string;
directory: string
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>;
abort: (input: { path: { id: string } }) => Promise<unknown>
prompt: (input: {
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
};
};
};
path: { id: string }
body: { parts: Array<{ type: "text"; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
}
}
const isRuntimeFallbackEnabled =
hooks.runtimeFallback !== null &&
hooks.runtimeFallback !== undefined &&
(typeof args.pluginConfig.runtime_fallback === "boolean"
? args.pluginConfig.runtime_fallback
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
const isModelFallbackEnabled =
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
: (args.pluginConfig.runtime_fallback?.enabled ?? false))
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>();
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
const lastHandledModelErrorMessageID = new Map<string, string>()
const lastHandledRetryStatusKey = new Map<string, string>()
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
await Promise.resolve(hooks.sessionNotification?.(input));
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
await Promise.resolve(hooks.rulesInjector?.event?.(input));
await Promise.resolve(hooks.thinkMode?.event?.(input));
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
await Promise.resolve(hooks.ralphLoop?.event?.(input));
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
};
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
await Promise.resolve(hooks.sessionNotification?.(input))
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
await Promise.resolve(hooks.rulesInjector?.event?.(input))
await Promise.resolve(hooks.thinkMode?.event?.(input))
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
await Promise.resolve(hooks.atlasHook?.handler?.(input))
}
const recentSyntheticIdles = new Map<string, number>();
const recentRealIdles = new Map<string, number>();
const DEDUP_WINDOW_MS = 500;
const recentSyntheticIdles = new Map<string, number>()
const recentRealIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
const shouldAutoRetrySession = (sessionID: string): boolean => {
if (syncSubagentSessions.has(sessionID)) return true;
const mainSessionID = getMainSessionID();
if (mainSessionID) return sessionID === mainSessionID;
if (syncSubagentSessions.has(sessionID)) return true
const mainSessionID = getMainSessionID()
if (mainSessionID) return sessionID === mainSessionID
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
// In that case, treat any non-subagent session as the "main" interactive session.
return !subagentSessions.has(sessionID);
};
return !subagentSessions.has(sessionID)
}
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
@@ -177,227 +173,230 @@ export function createEventHandler(args: {
recentRealIdles,
now: Date.now(),
dedupWindowMs: DEDUP_WINDOW_MS,
});
})
if (input.event.type === "session.idle") {
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
| string
| undefined;
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
if (sessionID) {
const emittedAt = recentSyntheticIdles.get(sessionID);
const emittedAt = recentSyntheticIdles.get(sessionID)
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentSyntheticIdles.delete(sessionID);
return;
recentSyntheticIdles.delete(sessionID)
return
}
recentRealIdles.set(sessionID, Date.now());
recentRealIdles.set(sessionID, Date.now())
}
}
await dispatchToHooks(input);
await dispatchToHooks(input)
const syntheticIdle = normalizeSessionStatusToIdle(input);
const syntheticIdle = normalizeSessionStatusToIdle(input)
if (syntheticIdle) {
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
const emittedAt = recentRealIdles.get(sessionID);
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
const emittedAt = recentRealIdles.get(sessionID)
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentRealIdles.delete(sessionID);
return;
recentRealIdles.delete(sessionID)
return
}
recentSyntheticIdles.set(sessionID, Date.now());
await dispatchToHooks(syntheticIdle as EventInput);
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle as EventInput)
}
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
const { event } = input
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id);
setMainSession(sessionInfo?.id)
}
firstMessageVariantGate.markSessionCreated(sessionInfo);
firstMessageVariantGate.markSessionCreated(sessionInfo)
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
type: string
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
info?: { id?: string; parentID?: string; title?: string }
}
},
);
)
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined);
setMainSession(undefined)
}
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id);
lastHandledModelErrorMessageID.delete(sessionInfo.id);
lastHandledRetryStatusKey.delete(sessionInfo.id);
lastKnownModelBySession.delete(sessionInfo.id);
clearPendingModelFallback(sessionInfo.id);
clearSessionFallbackChain(sessionInfo.id);
resetMessageCursor(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
syncSubagentSessions.delete(sessionInfo.id);
deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients();
clearSessionAgent(sessionInfo.id)
lastHandledModelErrorMessageID.delete(sessionInfo.id)
lastHandledRetryStatusKey.delete(sessionInfo.id)
lastKnownModelBySession.delete(sessionInfo.id)
clearPendingModelFallback(sessionInfo.id)
clearSessionFallbackChain(sessionInfo.id)
resetMessageCursor(sessionInfo.id)
firstMessageVariantGate.clear(sessionInfo.id)
clearSessionModel(sessionInfo.id)
syncSubagentSessions.delete(sessionInfo.id)
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
await lspManager.cleanupTempDirectoryClients()
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
});
})
}
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined;
const sessionID = info?.sessionID as string | undefined;
const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined;
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const agent = info?.agent as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "user") {
if (agent) {
updateSessionAgent(sessionID, agent);
updateSessionAgent(sessionID, agent)
}
const providerID = info?.providerID as string | undefined;
const modelID = info?.modelID as string | undefined;
const providerID = info?.providerID as string | undefined
const modelID = info?.modelID as string | undefined
if (providerID && modelID) {
lastKnownModelBySession.set(sessionID, { providerID, modelID });
setSessionModel(sessionID, { providerID, modelID });
lastKnownModelBySession.set(sessionID, { providerID, modelID })
setSessionModel(sessionID, { providerID, modelID })
}
}
// Model fallback: in practice, API/model failures often surface as assistant message errors.
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
try {
const assistantMessageID = info?.id as string | undefined;
const assistantError = info?.error;
const assistantMessageID = info?.id as string | undefined
const assistantError = info?.error
if (assistantMessageID && assistantError) {
const lastHandled = lastHandledModelErrorMessageID.get(sessionID);
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
if (lastHandled === assistantMessageID) {
return;
return
}
const errorName = extractErrorName(assistantError);
const errorMessage = extractErrorMessage(assistantError);
const errorInfo = { name: errorName, message: errorMessage };
const errorName = extractErrorName(assistantError)
const errorMessage = extractErrorMessage(assistantError)
const errorInfo = { name: errorName, message: errorMessage }
if (shouldRetryError(errorInfo)) {
// Prefer the agent/model/provider from the assistant message payload.
let agentName = agent ?? getSessionAgent(sessionID);
let agentName = agent ?? getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus";
agentName = "sisyphus"
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus";
agentName = "hephaestus"
} else {
agentName = "sisyphus";
agentName = "sisyphus"
}
}
if (agentName) {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
const currentModel = normalizeFallbackModelID(rawModel);
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
const currentModel = normalizeFallbackModelID(rawModel)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
.catch(() => {})
}
}
}
}
} catch (err) {
log("[event] model-fallback error in message.updated:", { sessionID, error: err });
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
}
}
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
const sessionID = props?.sessionID as string | undefined
const status = props?.status as
| { type?: string; attempt?: number; message?: string; next?: number }
| undefined
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
if (sessionID && status?.type === "retry") {
try {
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
const retryMessage = typeof status.message === "string" ? status.message : ""
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return;
return
}
lastHandledRetryStatusKey.set(sessionID, retryKey);
lastHandledRetryStatusKey.set(sessionID, retryKey)
const errorInfo = { name: undefined as string | undefined, message: retryMessage };
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
if (shouldRetryError(errorInfo)) {
let agentName = getSessionAgent(sessionID);
let agentName = getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
agentName = "sisyphus";
agentName = "sisyphus"
} else if (retryMessage.includes("gpt-5")) {
agentName = "hephaestus";
agentName = "hephaestus"
} else {
agentName = "sisyphus";
agentName = "sisyphus"
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(retryMessage);
const lastKnown = lastKnownModelBySession.get(sessionID);
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const parsed = extractProviderModelFromErrorMessage(retryMessage)
const lastKnown = lastKnownModelBySession.get(sessionID)
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
.catch(() => {})
}
}
}
} catch (err) {
log("[event] model-fallback error in session.status:", { sessionID, error: err });
log("[event] model-fallback error in session.status:", { sessionID, error: err })
}
}
}
if (event.type === "session.error") {
try {
const sessionID = props?.sessionID as string | undefined;
const error = props?.error;
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const errorName = extractErrorName(error);
const errorMessage = extractErrorMessage(error);
const errorInfo = { name: errorName, message: errorMessage };
const errorName = extractErrorName(error)
const errorMessage = extractErrorMessage(error)
const errorInfo = { name: errorName, message: errorMessage }
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
if (hooks.sessionRecovery?.isRecoverableError(error)) {
@@ -406,8 +405,8 @@ export function createEventHandler(args: {
role: "assistant" as const,
sessionID,
error,
};
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo);
}
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
if (
recovered &&
@@ -421,52 +420,53 @@ export function createEventHandler(args: {
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
.catch(() => {})
}
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
let agentName = getSessionAgent(sessionID);
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
let agentName = getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus";
agentName = "sisyphus"
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus";
agentName = "hephaestus"
} else {
agentName = "sisyphus";
agentName = "sisyphus"
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage);
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const parsed = extractProviderModelFromErrorMessage(errorMessage)
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
}
}
}
} catch (err) {
const sessionID = props?.sessionID as string | undefined;
log("[event] model-fallback error in session.error:", { sessionID, error: err });
const sessionID = props?.sessionID as string | undefined
log("[event] model-fallback error in session.error:", { sessionID, error: err })
}
}
};
}
}

View File

@@ -82,7 +82,7 @@ export function createSessionHooks(args: {
isHookEnabled("preemptive-compaction") &&
pluginConfig.experimental?.preemptive_compaction
? safeHook("preemptive-compaction", () =>
createPreemptiveCompactionHook(ctx, pluginConfig, modelCacheState))
createPreemptiveCompactionHook(ctx, modelCacheState))
: null
const sessionRecovery = isHookEnabled("session-recovery")
@@ -151,10 +151,9 @@ export function createSessionHooks(args: {
}
}
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
// Model fallback hook (configurable via disabled_hooks)
// This handles automatic model switching when model errors occur
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
const modelFallback = isHookEnabled("model-fallback")
? safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
@@ -175,7 +174,7 @@ export function createSessionHooks(args: {
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? safeHook("anthropic-context-window-limit-recovery", () =>
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental, pluginConfig }))
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
: null
const autoUpdateChecker = isHookEnabled("auto-update-checker")

View File

@@ -1,33 +0,0 @@
const { describe, expect, test, spyOn } = require("bun:test")
const sessionState = require("../features/claude-code-session-state")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
describe("createToolExecuteBeforeHandler session notification sessionID", () => {
test("uses main session fallback when input sessionID is empty", async () => {
const mainSessionID = "ses_main"
const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID)
let capturedSessionID: string | undefined
const hooks = {
sessionNotification: async (input) => {
capturedSessionID = input.event.properties?.sessionID
},
}
const handler = createToolExecuteBeforeHandler({
ctx: { client: { session: { messages: async () => ({ data: [] }) } } },
hooks,
})
await handler(
{ tool: "question", sessionID: "", callID: "call_q" },
{ args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } },
)
expect(getMainSessionIDSpy).toHaveBeenCalled()
expect(capturedSessionID).toBe(mainSessionID)
})
})
export {}

View File

@@ -31,60 +31,6 @@ describe("createToolExecuteBeforeHandler", () => {
await expect(run).resolves.toBeUndefined()
})
test("triggers session notification hook for question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
called = true
expect(input.event.type).toBe("tool.execute.before")
expect(input.event.properties?.sessionID).toBe("ses_q")
expect(input.event.properties?.tool).toBe("question")
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
const input = { tool: "question", sessionID: "ses_q", callID: "call_q" }
const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record<string, unknown> }
await handler(input, output)
expect(called).toBe(true)
})
test("does not trigger session notification hook for non-question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async () => {
called = true
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
await handler(
{ tool: "bash", sessionID: "ses_b", callID: "call_b" },
{ args: { command: "pwd" } as Record<string, unknown> },
)
expect(called).toBe(false)
})
describe("task tool subagent_type normalization", () => {
const emptyHooks = {}

View File

@@ -30,26 +30,6 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
const normalizedToolName = input.tool.toLowerCase()
if (
normalizedToolName === "question"
|| normalizedToolName === "ask_user_question"
|| normalizedToolName === "askuserquestion"
) {
const sessionID = input.sessionID || getMainSessionID()
await hooks.sessionNotification?.({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: input.tool,
args: output.args,
},
},
})
}
if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined

View File

@@ -1,129 +1,78 @@
import { spawn } from "node:child_process";
import { getHomeDirectory } from "./home-directory";
import { findBashPath, findZshPath } from "./shell-path";
import { spawn } from "node:child_process"
import { getHomeDirectory } from "./home-directory"
import { findBashPath, findZshPath } from "./shell-path"
export interface CommandResult {
exitCode: number;
stdout?: string;
stderr?: string;
exitCode: number
stdout?: string
stderr?: string
}
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const SIGKILL_GRACE_MS = 5_000;
export interface ExecuteHookOptions {
forceZsh?: boolean;
zshPath?: string;
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
timeoutMs?: number;
forceZsh?: boolean
zshPath?: string
}
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
): Promise<CommandResult> {
const home = getHomeDirectory();
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
const home = getHomeDirectory()
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
let finalCommand = expandedCommand;
let finalCommand = expandedCommand
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath);
const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`;
} else {
const bashPath = findBashPath();
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`;
}
}
}
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath)
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`
} else {
const bashPath = findBashPath()
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`
}
}
}
return new Promise(resolve => {
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | null = null;
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
const isWin32 = process.platform === "win32";
const proc = spawn(finalCommand, {
cwd,
shell: true,
detached: !isWin32,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
});
let stdout = ""
let stderr = ""
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.stdin?.on("error", () => {});
proc.stdin?.write(stdin);
proc.stdin?.end();
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
const settle = (result: CommandResult) => {
if (settled) return;
settled = true;
if (killTimer) clearTimeout(killTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
resolve(result);
};
proc.on("close", code => {
settle({
exitCode: code ?? 1,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
});
proc.on("error", err => {
settle({ exitCode: 1, stderr: err.message });
});
const killProcessGroup = (signal: NodeJS.Signals) => {
try {
if (!isWin32 && proc.pid) {
try {
process.kill(-proc.pid, signal);
} catch {
proc.kill(signal);
}
} else {
proc.kill(signal);
}
} catch {}
};
const timeoutTimer = setTimeout(() => {
if (settled) return;
// Kill entire process group to avoid orphaned children
killProcessGroup("SIGTERM");
killTimer = setTimeout(() => {
if (settled) return;
killProcessGroup("SIGKILL");
}, SIGKILL_GRACE_MS);
// Append timeout notice to stderr
stderr += `\nHook command timed out after ${timeoutMs}ms`;
}, timeoutMs);
// Don't let the timeout timer keep the process alive
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
timeoutTimer.unref();
}
});
proc.on("error", (err) => {
resolve({ exitCode: 1, stderr: err.message })
})
})
}

View File

@@ -199,7 +199,7 @@ export async function fetchAvailableModels(
// Handle both string[] (legacy) and object[] (with metadata) formats
const modelId = typeof modelItem === 'string'
? modelItem
: modelItem?.id
: (modelItem as any)?.id
if (modelId) {
modelSet.add(`${providerId}/${modelId}`)

View File

@@ -0,0 +1,52 @@
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
}
}

View File

@@ -0,0 +1,20 @@
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
}

View File

@@ -0,0 +1,7 @@
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>

View File

@@ -0,0 +1,39 @@
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
}

View File

@@ -1,18 +1,14 @@
const store = new Map<string, Record<string, boolean>>();
const store = new Map<string, Record<string, boolean>>()
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
store.set(sessionID, { ...tools });
store.set(sessionID, { ...tools })
}
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
const tools = store.get(sessionID);
return tools ? { ...tools } : undefined;
}
export function deleteSessionTools(sessionID: string): void {
store.delete(sessionID);
const tools = store.get(sessionID)
return tools ? { ...tools } : undefined
}
export function clearSessionTools(): void {
store.clear();
store.clear()
}

View File

@@ -77,40 +77,13 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
storeToolMetadata(ctx.sessionID, callID, meta)
}
const shouldBlock = args.block === true
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
const isActive = task.status === "pending" || task.status === "running"
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(resolvedTask, client, {
return await formatFullSession(task, client, {
includeThinking,
messageLimit: args.message_limit,
sinceMessageId: args.since_message_id,
@@ -119,15 +92,44 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
})
}
if (resolvedTask.status === "completed") {
return await formatTaskResult(resolvedTask, 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 === "error" || resolvedTask.status === "cancelled" || resolvedTask.status === "interrupt") {
return formatTaskStatus(resolvedTask)
if (task.status === "error" || task.status === "cancelled" || task.status === "interrupt") {
return formatTaskStatus(task)
}
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)}`
} catch (error) {
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
}

View File

@@ -339,48 +339,6 @@ 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

View File

@@ -2,11 +2,6 @@ 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
@@ -25,8 +20,9 @@ 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: SDKMessage) => m.info?.role === "assistant" || m.info?.role === "tool"
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (relevantMessages.length === 0) {
@@ -38,7 +34,8 @@ export async function processMessages(
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
// Sort by time ascending (oldest first) to process messages in order
const sortedMessages = [...relevantMessages].sort((a: SDKMessage, b: SDKMessage) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
const timeA = a.info?.time?.created ?? 0
const timeB = b.info?.time?.created ?? 0
return timeA - timeB
@@ -55,11 +52,12 @@ export async function processMessages(
const extractedContent: string[] = []
for (const message of newMessages) {
for (const part of message.parts ?? []) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const part of (message as any).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 as string) === "tool_result") {
} else if (part.type === "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) {

View File

@@ -0,0 +1,76 @@
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" }
}

View File

@@ -39,7 +39,7 @@ export async function createOrGetSession(
body: {
parentID: toolContext.sessionID,
title: `${args.description} (@${args.subagent_type} subagent)`,
} as Record<string, unknown>,
} as any,
query: {
directory: parentDirectory,
},

View File

@@ -0,0 +1,93 @@
import { consumeNewMessages, type CursorMessage } from "../../shared/session-cursor"
type SessionMessagePart = {
type: string
text?: string
content?: unknown
}
export type SessionMessage = CursorMessage & {
info?: CursorMessage["info"] & { role?: string }
parts?: SessionMessagePart[]
}
function getRole(message: SessionMessage): string | null {
const role = message.info?.role
return typeof role === "string" ? role : null
}
function getCreatedTime(message: SessionMessage): number {
const time = message.info?.time
if (typeof time === "number") return time
if (typeof time === "string") return Number(time) || 0
const created = time?.created
if (typeof created === "number") return created
if (typeof created === "string") return Number(created) || 0
return 0
}
function isRelevantRole(role: string | null): boolean {
return role === "assistant" || role === "tool"
}
function extractTextFromParts(parts: SessionMessagePart[] | undefined): string[] {
if (!parts) return []
const extracted: string[] = []
for (const part of parts) {
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extracted.push(part.text)
continue
}
if (part.type !== "tool_result") continue
const content = part.content
if (typeof content === "string" && content) {
extracted.push(content)
continue
}
if (!Array.isArray(content)) continue
for (const block of content) {
if (typeof block !== "object" || block === null) continue
const record = block as Record<string, unknown>
const typeValue = record["type"]
const textValue = record["text"]
if (
(typeValue === "text" || typeValue === "reasoning") &&
typeof textValue === "string" &&
textValue
) {
extracted.push(textValue)
}
}
}
return extracted
}
export function extractNewSessionOutput(
sessionID: string,
messages: SessionMessage[],
): { output: string; hasNewOutput: boolean } {
const relevantMessages = messages.filter((message) =>
isRelevantRole(getRole(message)),
)
if (relevantMessages.length === 0) {
return { output: "", hasNewOutput: false }
}
const sortedMessages = [...relevantMessages].sort(
(a, b) => getCreatedTime(a) - getCreatedTime(b),
)
const newMessages = consumeNewMessages(sessionID, sortedMessages)
if (newMessages.length === 0) {
return { output: "", hasNewOutput: false }
}
const chunks: string[] = []
for (const message of newMessages) {
chunks.push(...extractTextFromParts(message.parts))
}
const output = chunks.filter((text) => text.length > 0).join("\n\n")
return { output, hasNewOutput: output.length > 0 }
}

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