Compare commits
72 Commits
feat/hashl
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d27ff56b | ||
|
|
017c18c1b3 | ||
|
|
fb194fc944 | ||
|
|
10c25d1d47 | ||
|
|
86fcade9a4 | ||
|
|
5bc3a9e0db | ||
|
|
810ebec1cd | ||
|
|
8f7ed2988a | ||
|
|
7ff8352a0a | ||
|
|
1e060e9028 | ||
|
|
df02c73a54 | ||
|
|
52658ac1c4 | ||
|
|
fab820e919 | ||
|
|
6f54404a51 | ||
|
|
a3169c9287 | ||
|
|
0639ce8df7 | ||
|
|
685b8023dd | ||
|
|
c505989ad4 | ||
|
|
088984a8d4 | ||
|
|
0b69a6c507 | ||
|
|
5fe1640f2a | ||
|
|
ad01f60e99 | ||
|
|
87d6b2b519 | ||
|
|
b7b6721796 | ||
|
|
0c59d2dbe7 | ||
|
|
52d366e866 | ||
|
|
9cd6fc6135 | ||
|
|
f872f5e171 | ||
|
|
f500fb0286 | ||
|
|
9a94e12065 | ||
|
|
808a50d808 | ||
|
|
a263188abd | ||
|
|
155ed5248d | ||
|
|
ed5a2fe393 | ||
|
|
cd504a2694 | ||
|
|
e556c4a5c8 | ||
|
|
be7f408049 | ||
|
|
2ab40124ee | ||
|
|
840c612be8 | ||
|
|
235bb58779 | ||
|
|
ace1790c72 | ||
|
|
31eb7f5d28 | ||
|
|
6b5622c62f | ||
|
|
cf0d157673 | ||
|
|
adf62267aa | ||
|
|
9f64e2a869 | ||
|
|
e00f461eb1 | ||
|
|
da6c54ed93 | ||
|
|
1d99fdf843 | ||
|
|
de70c3a332 | ||
|
|
5e07dfe19b | ||
|
|
2acf6fa124 | ||
|
|
7e5872935a | ||
|
|
6458fe9fce | ||
|
|
640d9fb773 | ||
|
|
fc1b6e4917 | ||
|
|
a0e57c13c3 | ||
|
|
997db0e05b | ||
|
|
565ab8c13a | ||
|
|
15519b9580 | ||
|
|
b174513725 | ||
|
|
465f5e13a8 | ||
|
|
73453a7191 | ||
|
|
fcb90d92a4 | ||
|
|
ddf426c4b3 | ||
|
|
a882e6f027 | ||
|
|
dab2f90051 | ||
|
|
99f4c7e222 | ||
|
|
54d0dcde48 | ||
|
|
159ade05cc | ||
|
|
55b9ad60d8 | ||
|
|
e997e0071c |
61
.issue-comment-2064.md
Normal file
61
.issue-comment-2064.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
[sisyphus-bot]
|
||||||
|
|
||||||
|
## Confirmed Bug
|
||||||
|
|
||||||
|
We have identified the root cause of this issue. The bug is in the config writing logic during installation.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
**File:** `src/cli/config-manager/write-omo-config.ts` (line 46)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const merged = deepMergeRecord(existing, newConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
When a user runs `oh-my-opencode install` (even just to update settings), the installer:
|
||||||
|
1. Reads the existing config (with user's custom model settings)
|
||||||
|
2. Generates a **new** config based on detected provider availability
|
||||||
|
3. Calls `deepMergeRecord(existing, newConfig)`
|
||||||
|
4. Writes the result back
|
||||||
|
|
||||||
|
**The problem:** `deepMergeRecord` overwrites values in `existing` with values from `newConfig`. This means your custom `"model": "openai/gpt-5.2-codex"` gets overwritten by the generated default model (e.g., `anthropic/claude-opus-4-6` if Claude is available).
|
||||||
|
|
||||||
|
### Why This Happens
|
||||||
|
|
||||||
|
Looking at `deepMergeRecord` (line 24-25):
|
||||||
|
```typescript
|
||||||
|
} else if (sourceValue !== undefined) {
|
||||||
|
result[key] = sourceValue as TTarget[keyof TTarget]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any defined value in the source (generated config) overwrites the target (user's config).
|
||||||
|
|
||||||
|
### Fix Approach
|
||||||
|
|
||||||
|
The merge direction should be reversed to respect user overrides:
|
||||||
|
```typescript
|
||||||
|
const merged = deepMergeRecord(newConfig, existing)
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- User's explicit settings take precedence
|
||||||
|
- Only new/undefined keys get populated from generated defaults
|
||||||
|
- Custom model choices are preserved
|
||||||
|
|
||||||
|
### SEVERITY: HIGH
|
||||||
|
|
||||||
|
- **Impact:** User configuration is overwritten without consent
|
||||||
|
- **Affected Files:**
|
||||||
|
- `src/cli/config-manager/write-omo-config.ts`
|
||||||
|
- `src/cli/config-manager/deep-merge-record.ts`
|
||||||
|
- **Trigger:** Running `oh-my-opencode install` (even for unrelated updates)
|
||||||
|
|
||||||
|
### Workaround (Until Fix)
|
||||||
|
|
||||||
|
Backup your config before running install:
|
||||||
|
```bash
|
||||||
|
cp ~/.config/opencode/oh-my-opencode.jsonc ~/.config/opencode/oh-my-opencode.jsonc.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
We're working on a fix that will preserve your explicit model configurations.
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -1,10 +1,10 @@
|
|||||||
# oh-my-opencode — OpenCode Plugin
|
# oh-my-opencode — OpenCode Plugin
|
||||||
|
|
||||||
**Generated:** 2026-02-21 | **Commit:** 86e3c7d1 | **Branch:** dev
|
**Generated:** 2026-02-24 | **Commit:** fcb90d92 | **Branch:** dev
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC.
|
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC.
|
||||||
|
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
|
|
||||||
@@ -14,14 +14,14 @@ oh-my-opencode/
|
|||||||
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
||||||
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
||||||
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
||||||
│ ├── hooks/ # 44 hooks across 39 directories + 6 standalone files
|
| `hooks/` # 46 hooks across 39 directories + 6 standalone files
|
||||||
│ ├── tools/ # 26 tools across 15 directories
|
│ ├── tools/ # 26 tools across 15 directories
|
||||||
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
|
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
|
||||||
│ ├── shared/ # 100+ utility files in 13 categories
|
│ ├── shared/ # 100+ utility files in 13 categories
|
||||||
│ ├── config/ # Zod v4 schema system (22+ files)
|
│ ├── config/ # Zod v4 schema system (22+ files)
|
||||||
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
|
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
|
||||||
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
|
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
|
||||||
│ ├── plugin/ # 8 OpenCode hook handlers + 44 hook composition
|
│ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition
|
||||||
│ └── plugin-handlers/ # 6-phase config loading pipeline
|
│ └── plugin-handlers/ # 6-phase config loading pipeline
|
||||||
├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries
|
├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries
|
||||||
└── local-ignore/ # Dev-only test fixtures
|
└── local-ignore/ # Dev-only test fixtures
|
||||||
@@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx)
|
|||||||
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
||||||
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
||||||
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
||||||
├─→ createHooks() # 3-tier: Core(35) + Continuation(7) + Skill(2) = 44 hooks
|
├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks
|
||||||
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
|
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom
|
|||||||
|
|
||||||
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
|
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
|
||||||
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
||||||
- **Hook tiers**: Session (22) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2)
|
- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2)
|
||||||
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
||||||
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
|
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
|
||||||
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys
|
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys
|
||||||
|
|||||||
@@ -960,6 +960,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"allow_non_gpt_model": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -3248,6 +3251,11 @@
|
|||||||
"prompt_append": {
|
"prompt_append": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"max_prompt_tokens": {
|
||||||
|
"type": "integer",
|
||||||
|
"exclusiveMinimum": 0,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
"is_unstable_agent": {
|
"is_unstable_agent": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
31
bun.lock
31
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"@opencode-ai/sdk": "^1.1.19",
|
"@opencode-ai/sdk": "^1.1.19",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -28,13 +29,13 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.8.1",
|
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||||
"oh-my-opencode-darwin-x64": "3.8.1",
|
"oh-my-opencode-darwin-x64": "3.8.5",
|
||||||
"oh-my-opencode-linux-arm64": "3.8.1",
|
"oh-my-opencode-linux-arm64": "3.8.5",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.8.1",
|
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
||||||
"oh-my-opencode-linux-x64": "3.8.1",
|
"oh-my-opencode-linux-x64": "3.8.5",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.8.1",
|
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
||||||
"oh-my-opencode-windows-x64": "3.8.1",
|
"oh-my-opencode-windows-x64": "3.8.5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,6 +139,8 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
@@ -228,19 +231,19 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.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.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="],
|
||||||
|
|
||||||
"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.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="],
|
||||||
|
|
||||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode",
|
"name": "oh-my-opencode",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
"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",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"@opencode-ai/sdk": "^1.1.19",
|
"@opencode-ai/sdk": "^1.1.19",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -74,13 +75,13 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.8.4",
|
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||||
"oh-my-opencode-darwin-x64": "3.8.4",
|
"oh-my-opencode-darwin-x64": "3.8.5",
|
||||||
"oh-my-opencode-linux-arm64": "3.8.4",
|
"oh-my-opencode-linux-arm64": "3.8.5",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.8.4",
|
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
||||||
"oh-my-opencode-linux-x64": "3.8.4",
|
"oh-my-opencode-linux-x64": "3.8.5",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.8.4",
|
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
||||||
"oh-my-opencode-windows-x64": "3.8.4"
|
"oh-my-opencode-windows-x64": "3.8.5"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@ast-grep/cli",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-arm64",
|
"name": "oh-my-opencode-darwin-arm64",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64",
|
"name": "oh-my-opencode-darwin-x64",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64-musl",
|
"name": "oh-my-opencode-linux-arm64-musl",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64",
|
"name": "oh-my-opencode-linux-arm64",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl",
|
"name": "oh-my-opencode-linux-x64-musl",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64",
|
"name": "oh-my-opencode-linux-x64",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64",
|
"name": "oh-my-opencode-windows-x64",
|
||||||
"version": "3.8.4",
|
"version": "3.8.5",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1703,6 +1703,70 @@
|
|||||||
"created_at": "2026-02-23T19:27:59Z",
|
"created_at": "2026-02-23T19:27:59Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 2080
|
"pullRequestNo": 2080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PHP-Expert",
|
||||||
|
"id": 12047666,
|
||||||
|
"comment_id": 3951828700,
|
||||||
|
"created_at": "2026-02-24T13:27:18Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2098
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pantoria",
|
||||||
|
"id": 37699442,
|
||||||
|
"comment_id": 3953543578,
|
||||||
|
"created_at": "2026-02-24T17:12:31Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 1983
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "east-shine",
|
||||||
|
"id": 20237288,
|
||||||
|
"comment_id": 3957576758,
|
||||||
|
"created_at": "2026-02-25T08:19:34Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2113
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SupenBysz",
|
||||||
|
"id": 3314033,
|
||||||
|
"comment_id": 3962352704,
|
||||||
|
"created_at": "2026-02-25T22:00:54Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "zhzy0077",
|
||||||
|
"id": 8717471,
|
||||||
|
"comment_id": 3964015975,
|
||||||
|
"created_at": "2026-02-26T04:45:23Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2125
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "spacecowboy0416",
|
||||||
|
"id": 239068998,
|
||||||
|
"comment_id": 3964320737,
|
||||||
|
"created_at": "2026-02-26T06:05:27Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2126
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "imwxc",
|
||||||
|
"id": 49653609,
|
||||||
|
"comment_id": 3965127447,
|
||||||
|
"created_at": "2026-02-26T09:00:16Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2129
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maou-shonen",
|
||||||
|
"id": 22576780,
|
||||||
|
"comment_id": 3965445132,
|
||||||
|
"created_at": "2026-02-26T09:50:46Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 2131
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/ — Plugin Source
|
# src/ — Plugin Source
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Root source directory. Entry point `index.ts` orchestrates 4-step initialization
|
|||||||
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
|
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
|
||||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
|
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
|
||||||
| `create-hooks.ts` | 3-tier hook composition: Core(35) + Continuation(7) + Skill(2) |
|
| `create-hooks.ts` | 3-tier hook composition: Core(37) + Continuation(7) + Skill(2) |
|
||||||
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
|
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
|
||||||
|
|
||||||
## CONFIG LOADING
|
## CONFIG LOADING
|
||||||
@@ -32,9 +32,9 @@ loadPluginConfig(directory, ctx)
|
|||||||
|
|
||||||
```
|
```
|
||||||
createHooks()
|
createHooks()
|
||||||
├─→ createCoreHooks() # 35 hooks
|
├─→ createCoreHooks() # 37 hooks
|
||||||
│ ├─ createSessionHooks() # 21: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery, jsonErrorRecovery, sisyphusGptHephaestusReminder, anthropicEffort...
|
│ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort...
|
||||||
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, hashlineEditDiffEnhancer...
|
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
|
||||||
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
||||||
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...
|
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...
|
||||||
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/agents/ — 11 Agent Definitions
|
# src/agents/ — 11 Agent Definitions
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynam
|
|||||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||||
import type { CategoryConfig } from "../../config/schema"
|
import type { CategoryConfig } from "../../config/schema"
|
||||||
import { mergeCategories } from "../../shared/merge-categories"
|
import { mergeCategories } from "../../shared/merge-categories"
|
||||||
import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
|
||||||
|
|
||||||
import { getDefaultAtlasPrompt } from "./default"
|
import { getDefaultAtlasPrompt } from "./default"
|
||||||
import { getGptAtlasPrompt } from "./gpt"
|
import { getGptAtlasPrompt } from "./gpt"
|
||||||
@@ -100,11 +99,6 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||||
const restrictions = createAgentToolRestrictions([
|
|
||||||
"task",
|
|
||||||
"call_omo_agent",
|
|
||||||
])
|
|
||||||
|
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
description:
|
description:
|
||||||
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
||||||
@@ -113,7 +107,6 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||||
color: "#10B981",
|
color: "#10B981",
|
||||||
...restrictions,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseConfig as AgentConfig
|
return baseConfig as AgentConfig
|
||||||
|
|||||||
41
src/agents/env-context.test.ts
Normal file
41
src/agents/env-context.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import { createEnvContext } from "./env-context"
|
||||||
|
|
||||||
|
describe("createEnvContext", () => {
|
||||||
|
test("returns omo-env block with timezone and locale", () => {
|
||||||
|
// #given - no setup needed
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = createEnvContext()
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toContain("<omo-env>")
|
||||||
|
expect(result).toContain("</omo-env>")
|
||||||
|
expect(result).toContain("Timezone:")
|
||||||
|
expect(result).toContain("Locale:")
|
||||||
|
expect(result).not.toContain("Current date:")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not include time with seconds precision to preserve token cache", () => {
|
||||||
|
// #given - seconds-precision time changes every second, breaking cache on every request
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = createEnvContext()
|
||||||
|
|
||||||
|
// #then - no HH:MM:SS pattern anywhere in the output
|
||||||
|
expect(result).not.toMatch(/\d{1,2}:\d{2}:\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not include date or time fields since OpenCode already provides them", () => {
|
||||||
|
// #given - OpenCode's system.ts already injects date, platform, working directory
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = createEnvContext()
|
||||||
|
|
||||||
|
// #then - only timezone and locale remain; both are stable across requests
|
||||||
|
expect(result).not.toContain("Current date:")
|
||||||
|
expect(result).not.toContain("Current time:")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,32 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Creates OmO-specific environment context (time, timezone, locale).
|
* Creates OmO-specific environment context (timezone, locale).
|
||||||
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
||||||
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
||||||
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||||
*/
|
*/
|
||||||
export function createEnvContext(): string {
|
export function createEnvContext(): string {
|
||||||
const now = new Date()
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||||
|
|
||||||
const dateStr = now.toLocaleDateString(locale, {
|
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeStr = now.toLocaleTimeString(locale, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<omo-env>
|
<omo-env>
|
||||||
Current date: ${dateStr}
|
|
||||||
Current time: ${timeStr}
|
|
||||||
Timezone: ${timezone}
|
Timezone: ${timezone}
|
||||||
Locale: ${locale}
|
Locale: ${locale}
|
||||||
</omo-env>`
|
</omo-env>`
|
||||||
|
|||||||
@@ -448,6 +448,21 @@ ${oracleSection}
|
|||||||
4. **Run build** if applicable — exit code 0 required
|
4. **Run build** if applicable — exit code 0 required
|
||||||
5. **Tell user** what you verified and the results — keep it clear and helpful
|
5. **Tell user** what you verified and the results — keep it clear and helpful
|
||||||
|
|
||||||
|
### Auto-Commit Policy (MANDATORY for implementation/fix work)
|
||||||
|
|
||||||
|
1. **Auto-commit after implementation is complete** when the task includes feature/fix code changes
|
||||||
|
2. **Commit ONLY after verification gates pass**:
|
||||||
|
- \`lsp_diagnostics\` clean on all modified files
|
||||||
|
- Related tests pass
|
||||||
|
- Typecheck/build pass when applicable
|
||||||
|
3. **If any gate fails, DO NOT commit** — fix issues first, re-run verification, then commit
|
||||||
|
4. **Use Conventional Commits format** with meaningful intent-focused messages:
|
||||||
|
- \`feat(scope): add ...\` for new functionality
|
||||||
|
- \`fix(scope): resolve ...\` for bug fixes
|
||||||
|
- \`refactor(scope): simplify ...\` for internal restructuring
|
||||||
|
5. **Do not make placeholder commits** (\`wip\`, \`temp\`, \`update\`) or commit unverified code
|
||||||
|
6. **If user explicitly says not to commit**, skip commit and report that changes are left uncommitted
|
||||||
|
|
||||||
- **File edit** — \`lsp_diagnostics\` clean
|
- **File edit** — \`lsp_diagnostics\` clean
|
||||||
- **Build** — Exit code 0
|
- **Build** — Exit code 0
|
||||||
- **Tests** — Pass (or pre-existing failures noted)
|
- **Tests** — Pass (or pre-existing failures noted)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLibrarianAgent } from "./librarian"
|
|||||||
import { createExploreAgent } from "./explore"
|
import { createExploreAgent } from "./explore"
|
||||||
import { createMomusAgent } from "./momus"
|
import { createMomusAgent } from "./momus"
|
||||||
import { createMetisAgent } from "./metis"
|
import { createMetisAgent } from "./metis"
|
||||||
|
import { createAtlasAgent } from "./atlas"
|
||||||
|
|
||||||
const TEST_MODEL = "anthropic/claude-sonnet-4-5"
|
const TEST_MODEL = "anthropic/claude-sonnet-4-5"
|
||||||
|
|
||||||
@@ -96,4 +97,18 @@ describe("read-only agent tool restrictions", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Atlas", () => {
|
||||||
|
test("allows delegation tools for orchestration", () => {
|
||||||
|
// given
|
||||||
|
const agent = createAtlasAgent({ model: TEST_MODEL })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const permission = (agent.permission ?? {}) as Record<string, string>
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(permission["task"]).toBeUndefined()
|
||||||
|
expect(permission["call_omo_agent"]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { describe, test, expect } from "bun:test";
|
|||||||
import { isGptModel, isGeminiModel } from "./types";
|
import { isGptModel, isGeminiModel } from "./types";
|
||||||
|
|
||||||
describe("isGptModel", () => {
|
describe("isGptModel", () => {
|
||||||
test("standard openai provider models", () => {
|
test("standard openai provider gpt models", () => {
|
||||||
expect(isGptModel("openai/gpt-5.2")).toBe(true);
|
expect(isGptModel("openai/gpt-5.2")).toBe(true);
|
||||||
expect(isGptModel("openai/gpt-4o")).toBe(true);
|
expect(isGptModel("openai/gpt-4o")).toBe(true);
|
||||||
expect(isGptModel("openai/o1")).toBe(true);
|
});
|
||||||
expect(isGptModel("openai/o3-mini")).toBe(true);
|
|
||||||
|
test("o-series models are not gpt by name", () => {
|
||||||
|
expect(isGptModel("openai/o1")).toBe(false);
|
||||||
|
expect(isGptModel("openai/o3-mini")).toBe(false);
|
||||||
|
expect(isGptModel("litellm/o1")).toBe(false);
|
||||||
|
expect(isGptModel("litellm/o3-mini")).toBe(false);
|
||||||
|
expect(isGptModel("litellm/o4-mini")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("github copilot gpt models", () => {
|
test("github copilot gpt models", () => {
|
||||||
@@ -17,9 +23,6 @@ describe("isGptModel", () => {
|
|||||||
test("litellm proxied gpt models", () => {
|
test("litellm proxied gpt models", () => {
|
||||||
expect(isGptModel("litellm/gpt-5.2")).toBe(true);
|
expect(isGptModel("litellm/gpt-5.2")).toBe(true);
|
||||||
expect(isGptModel("litellm/gpt-4o")).toBe(true);
|
expect(isGptModel("litellm/gpt-4o")).toBe(true);
|
||||||
expect(isGptModel("litellm/o1")).toBe(true);
|
|
||||||
expect(isGptModel("litellm/o3-mini")).toBe(true);
|
|
||||||
expect(isGptModel("litellm/o4-mini")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("other proxied gpt models", () => {
|
test("other proxied gpt models", () => {
|
||||||
@@ -27,6 +30,11 @@ describe("isGptModel", () => {
|
|||||||
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
|
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("venice provider gpt models", () => {
|
||||||
|
expect(isGptModel("venice/gpt-5.2")).toBe(true);
|
||||||
|
expect(isGptModel("venice/gpt-4o")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("gpt4 prefix without hyphen (legacy naming)", () => {
|
test("gpt4 prefix without hyphen (legacy naming)", () => {
|
||||||
expect(isGptModel("litellm/gpt4o")).toBe(true);
|
expect(isGptModel("litellm/gpt4o")).toBe(true);
|
||||||
expect(isGptModel("ollama/gpt4")).toBe(true);
|
expect(isGptModel("ollama/gpt4")).toBe(true);
|
||||||
|
|||||||
@@ -70,14 +70,9 @@ function extractModelName(model: string): string {
|
|||||||
return model.includes("/") ? model.split("/").pop() ?? model : model
|
return model.includes("/") ? model.split("/").pop() ?? model : model
|
||||||
}
|
}
|
||||||
|
|
||||||
const GPT_MODEL_PREFIXES = ["gpt-", "gpt4", "o1", "o3", "o4"]
|
|
||||||
|
|
||||||
export function isGptModel(model: string): boolean {
|
export function isGptModel(model: string): boolean {
|
||||||
if (model.startsWith("openai/") || model.startsWith("github-copilot/gpt-"))
|
|
||||||
return true
|
|
||||||
|
|
||||||
const modelName = extractModelName(model).toLowerCase()
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
|
return modelName.includes("gpt")
|
||||||
}
|
}
|
||||||
|
|
||||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
|
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
|
||||||
|
|||||||
@@ -589,20 +589,22 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("hephaestus is created when github-copilot provider is connected", async () => {
|
test("hephaestus IS created when github-copilot is connected with a GPT model", async () => {
|
||||||
// #given - github-copilot provider has models available
|
// #given - github-copilot provider has gpt-5.3-codex available
|
||||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||||
new Set(["github-copilot/gpt-5.3-codex"])
|
new Set(["github-copilot/gpt-5.3-codex"])
|
||||||
)
|
)
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// #when
|
// #when
|
||||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
||||||
|
|
||||||
// #then
|
// #then - github-copilot is now a valid provider for hephaestus
|
||||||
expect(agents.hephaestus).toBeDefined()
|
expect(agents.hephaestus).toBeDefined()
|
||||||
} finally {
|
} finally {
|
||||||
fetchSpy.mockRestore()
|
fetchSpy.mockRestore()
|
||||||
|
cacheSpy.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -750,10 +750,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
|
||||||
"variant": "medium",
|
|
||||||
},
|
|
||||||
"librarian": {
|
"librarian": {
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
@@ -786,16 +782,12 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
|
||||||
"variant": "medium",
|
|
||||||
},
|
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
"model": "github-copilot/gemini-3-pro-preview",
|
||||||
"variant": "xhigh",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
@@ -824,10 +816,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
|
||||||
"variant": "medium",
|
|
||||||
},
|
|
||||||
"librarian": {
|
"librarian": {
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
@@ -860,16 +848,12 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
|
||||||
"variant": "medium",
|
|
||||||
},
|
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
"model": "github-copilot/gemini-3-pro-preview",
|
||||||
"variant": "xhigh",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
@@ -1285,7 +1269,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"model": "opencode/claude-haiku-4-5",
|
"model": "opencode/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"librarian": {
|
||||||
@@ -1321,14 +1305,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "xhigh",
|
"variant": "xhigh",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/cli/config-manager/ — CLI Installation Utilities
|
# src/cli/config-manager/ — CLI Installation Utilities
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
80
src/cli/config-manager/write-omo-config.test.ts
Normal file
80
src/cli/config-manager/write-omo-config.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import { parseJsonc } from "../../shared/jsonc-parser"
|
||||||
|
import type { InstallConfig } from "../types"
|
||||||
|
import { resetConfigContext } from "./config-context"
|
||||||
|
import { generateOmoConfig } from "./generate-omo-config"
|
||||||
|
import { writeOmoConfig } from "./write-omo-config"
|
||||||
|
|
||||||
|
const installConfig: InstallConfig = {
|
||||||
|
hasClaude: true,
|
||||||
|
isMax20: true,
|
||||||
|
hasOpenAI: true,
|
||||||
|
hasGemini: true,
|
||||||
|
hasCopilot: false,
|
||||||
|
hasOpencodeZen: false,
|
||||||
|
hasZaiCodingPlan: false,
|
||||||
|
hasKimiForCoding: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeOmoConfig", () => {
|
||||||
|
let testConfigDir = ""
|
||||||
|
let testConfigPath = ""
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testConfigDir = join(tmpdir(), `omo-write-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||||
|
testConfigPath = join(testConfigDir, "oh-my-opencode.json")
|
||||||
|
|
||||||
|
mkdirSync(testConfigDir, { recursive: true })
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = testConfigDir
|
||||||
|
resetConfigContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testConfigDir, { recursive: true, force: true })
|
||||||
|
resetConfigContext()
|
||||||
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves existing user values while adding new defaults", () => {
|
||||||
|
// given
|
||||||
|
const existingConfig = {
|
||||||
|
agents: {
|
||||||
|
sisyphus: {
|
||||||
|
model: "custom/provider-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled_hooks: ["comment-checker"],
|
||||||
|
}
|
||||||
|
writeFileSync(testConfigPath, JSON.stringify(existingConfig, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
|
const generatedDefaults = generateOmoConfig(installConfig)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = writeOmoConfig(installConfig)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
const savedConfig = parseJsonc<Record<string, unknown>>(readFileSync(testConfigPath, "utf-8"))
|
||||||
|
const savedAgents = getRecord(savedConfig.agents)
|
||||||
|
const savedSisyphus = getRecord(savedAgents.sisyphus)
|
||||||
|
expect(savedSisyphus.model).toBe("custom/provider-model")
|
||||||
|
expect(savedConfig.disabled_hooks).toEqual(["comment-checker"])
|
||||||
|
|
||||||
|
for (const defaultKey of Object.keys(generatedDefaults)) {
|
||||||
|
expect(savedConfig).toHaveProperty(defaultKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -43,7 +43,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
|||||||
return { success: true, configPath: omoConfigPath }
|
return { success: true, configPath: omoConfigPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = deepMergeRecord(existing, newConfig)
|
const merged = deepMergeRecord(newConfig, existing)
|
||||||
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
|
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
if (parseErr instanceof SyntaxError) {
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
},
|
},
|
||||||
hephaestus: {
|
hephaestus: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
],
|
],
|
||||||
requiresProvider: ["openai", "github-copilot", "opencode"],
|
requiresProvider: ["openai", "opencode"],
|
||||||
},
|
},
|
||||||
oracle: {
|
oracle: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
@@ -100,14 +100,14 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
},
|
},
|
||||||
ultrabrain: {
|
ultrabrain: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
deep: {
|
deep: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
@@ -131,7 +131,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -421,16 +421,15 @@ describe("generateModelConfig", () => {
|
|||||||
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Hephaestus is created when Copilot is available (github-copilot provider connected)", () => {
|
test("Hephaestus is NOT created when only Copilot is available (gpt-5.3-codex unavailable on github-copilot)", () => {
|
||||||
// #given
|
// #given
|
||||||
const config = createConfig({ hasCopilot: true })
|
const config = createConfig({ hasCopilot: true })
|
||||||
|
|
||||||
// #when
|
// #when
|
||||||
const result = generateModelConfig(config)
|
const result = generateModelConfig(config)
|
||||||
|
|
||||||
// #then
|
// #then - hephaestus is omitted because gpt-5.3-codex is not available on github-copilot
|
||||||
expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.3-codex")
|
expect(result.agents?.hephaestus).toBeUndefined()
|
||||||
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {
|
test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/cli/run/ — Non-Interactive Session Launcher
|
# src/cli/run/ — Non-Interactive Session Launcher
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, spyOn } from "bun:test"
|
const { describe, it, expect, spyOn } = require("bun:test")
|
||||||
import type { RunContext } from "./types"
|
import type { RunContext } from "./types"
|
||||||
import { createEventState } from "./events"
|
import { createEventState } from "./events"
|
||||||
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
|
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
|
||||||
@@ -235,9 +235,7 @@ describe("handleMessagePartUpdated", () => {
|
|||||||
|
|
||||||
it("prints completion metadata once when assistant text part is completed", () => {
|
it("prints completion metadata once when assistant text part is completed", () => {
|
||||||
// given
|
// given
|
||||||
const nowSpy = spyOn(Date, "now")
|
const nowSpy = spyOn(Date, "now").mockReturnValue(3400)
|
||||||
nowSpy.mockReturnValueOnce(1000)
|
|
||||||
nowSpy.mockReturnValueOnce(3400)
|
|
||||||
|
|
||||||
const ctx = createMockContext("ses_main")
|
const ctx = createMockContext("ses_main")
|
||||||
const state = createEventState()
|
const state = createEventState()
|
||||||
@@ -259,6 +257,7 @@ describe("handleMessagePartUpdated", () => {
|
|||||||
} as any,
|
} as any,
|
||||||
state,
|
state,
|
||||||
)
|
)
|
||||||
|
state.messageStartedAtById["msg_1"] = 1000
|
||||||
|
|
||||||
// when
|
// when
|
||||||
handleMessagePartUpdated(
|
handleMessagePartUpdated(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface EventState {
|
|||||||
currentTool: string | null
|
currentTool: string | null
|
||||||
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
||||||
hasReceivedMeaningfulWork: boolean
|
hasReceivedMeaningfulWork: boolean
|
||||||
|
/** Timestamp of the last received event (for watchdog detection) */
|
||||||
|
lastEventTimestamp: number
|
||||||
/** Count of assistant messages for the main session */
|
/** Count of assistant messages for the main session */
|
||||||
messageCount: number
|
messageCount: number
|
||||||
/** Current agent name from the latest assistant message */
|
/** Current agent name from the latest assistant message */
|
||||||
@@ -54,6 +56,7 @@ export function createEventState(): EventState {
|
|||||||
lastPartText: "",
|
lastPartText: "",
|
||||||
currentTool: null,
|
currentTool: null,
|
||||||
hasReceivedMeaningfulWork: false,
|
hasReceivedMeaningfulWork: false,
|
||||||
|
lastEventTimestamp: Date.now(),
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
currentAgent: null,
|
currentAgent: null,
|
||||||
currentModel: null,
|
currentModel: null,
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export async function processEvents(
|
|||||||
logEventVerbose(ctx, payload)
|
logEventVerbose(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update last event timestamp for watchdog detection
|
||||||
|
state.lastEventTimestamp = Date.now()
|
||||||
|
|
||||||
handleSessionError(ctx, payload, state)
|
handleSessionError(ctx, payload, state)
|
||||||
handleSessionIdle(ctx, payload, state)
|
handleSessionIdle(ctx, payload, state)
|
||||||
handleSessionStatus(ctx, payload, state)
|
handleSessionStatus(ctx, payload, state)
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ const DEFAULT_POLL_INTERVAL_MS = 500
|
|||||||
const DEFAULT_REQUIRED_CONSECUTIVE = 1
|
const DEFAULT_REQUIRED_CONSECUTIVE = 1
|
||||||
const ERROR_GRACE_CYCLES = 3
|
const ERROR_GRACE_CYCLES = 3
|
||||||
const MIN_STABILIZATION_MS = 1_000
|
const MIN_STABILIZATION_MS = 1_000
|
||||||
|
const DEFAULT_EVENT_WATCHDOG_MS = 30_000 // 30 seconds
|
||||||
|
const DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS = 60_000 // 60 seconds
|
||||||
|
|
||||||
export interface PollOptions {
|
export interface PollOptions {
|
||||||
pollIntervalMs?: number
|
pollIntervalMs?: number
|
||||||
requiredConsecutive?: number
|
requiredConsecutive?: number
|
||||||
minStabilizationMs?: number
|
minStabilizationMs?: number
|
||||||
|
eventWatchdogMs?: number
|
||||||
|
secondaryMeaningfulWorkTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollForCompletion(
|
export async function pollForCompletion(
|
||||||
@@ -28,9 +32,15 @@ export async function pollForCompletion(
|
|||||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||||
const minStabilizationMs =
|
const minStabilizationMs =
|
||||||
rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS
|
rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS
|
||||||
|
const eventWatchdogMs =
|
||||||
|
options.eventWatchdogMs ?? DEFAULT_EVENT_WATCHDOG_MS
|
||||||
|
const secondaryMeaningfulWorkTimeoutMs =
|
||||||
|
options.secondaryMeaningfulWorkTimeoutMs ??
|
||||||
|
DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS
|
||||||
let consecutiveCompleteChecks = 0
|
let consecutiveCompleteChecks = 0
|
||||||
let errorCycleCount = 0
|
let errorCycleCount = 0
|
||||||
let firstWorkTimestamp: number | null = null
|
let firstWorkTimestamp: number | null = null
|
||||||
|
let secondaryTimeoutChecked = false
|
||||||
const pollStartTimestamp = Date.now()
|
const pollStartTimestamp = Date.now()
|
||||||
|
|
||||||
while (!abortController.signal.aborted) {
|
while (!abortController.signal.aborted) {
|
||||||
@@ -59,7 +69,37 @@ export async function pollForCompletion(
|
|||||||
errorCycleCount = 0
|
errorCycleCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainSessionStatus = await getMainSessionStatus(ctx)
|
// Watchdog: if no events received for N seconds, verify session status via API
|
||||||
|
let mainSessionStatus: "idle" | "busy" | "retry" | null = null
|
||||||
|
if (eventState.lastEventTimestamp !== null) {
|
||||||
|
const timeSinceLastEvent = Date.now() - eventState.lastEventTimestamp
|
||||||
|
if (timeSinceLastEvent > eventWatchdogMs) {
|
||||||
|
// Events stopped coming - verify actual session state
|
||||||
|
console.log(
|
||||||
|
pc.yellow(
|
||||||
|
`\n No events for ${Math.round(
|
||||||
|
timeSinceLastEvent / 1000
|
||||||
|
)}s, verifying session status...`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Force check session status directly
|
||||||
|
mainSessionStatus = await getMainSessionStatus(ctx)
|
||||||
|
if (mainSessionStatus === "idle") {
|
||||||
|
eventState.mainSessionIdle = true
|
||||||
|
} else if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
|
||||||
|
eventState.mainSessionIdle = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset timestamp to avoid repeated checks
|
||||||
|
eventState.lastEventTimestamp = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only call getMainSessionStatus if watchdog didn't already check
|
||||||
|
if (mainSessionStatus === null) {
|
||||||
|
mainSessionStatus = await getMainSessionStatus(ctx)
|
||||||
|
}
|
||||||
if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
|
if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
|
||||||
eventState.mainSessionIdle = false
|
eventState.mainSessionIdle = false
|
||||||
} else if (mainSessionStatus === "idle") {
|
} else if (mainSessionStatus === "idle") {
|
||||||
@@ -81,6 +121,50 @@ export async function pollForCompletion(
|
|||||||
consecutiveCompleteChecks = 0
|
consecutiveCompleteChecks = 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary timeout: if we've been polling for reasonable time but haven't
|
||||||
|
// received meaningful work via events, check if there's active work via API
|
||||||
|
// Only check once to avoid unnecessary API calls every poll cycle
|
||||||
|
if (
|
||||||
|
Date.now() - pollStartTimestamp > secondaryMeaningfulWorkTimeoutMs &&
|
||||||
|
!secondaryTimeoutChecked
|
||||||
|
) {
|
||||||
|
secondaryTimeoutChecked = true
|
||||||
|
// Check if session actually has pending work (children, todos, etc.)
|
||||||
|
const childrenRes = await ctx.client.session.children({
|
||||||
|
path: { id: ctx.sessionID },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
const children = normalizeSDKResponse(childrenRes, [] as unknown[])
|
||||||
|
const todosRes = await ctx.client.session.todo({
|
||||||
|
path: { id: ctx.sessionID },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
const todos = normalizeSDKResponse(todosRes, [] as unknown[])
|
||||||
|
|
||||||
|
const hasActiveChildren =
|
||||||
|
Array.isArray(children) && children.length > 0
|
||||||
|
const hasActiveTodos =
|
||||||
|
Array.isArray(todos) &&
|
||||||
|
todos.some(
|
||||||
|
(t: unknown) =>
|
||||||
|
(t as { status?: string })?.status !== "completed" &&
|
||||||
|
(t as { status?: string })?.status !== "cancelled"
|
||||||
|
)
|
||||||
|
const hasActiveWork = hasActiveChildren || hasActiveTodos
|
||||||
|
|
||||||
|
if (hasActiveWork) {
|
||||||
|
// Assume meaningful work is happening even without events
|
||||||
|
eventState.hasReceivedMeaningfulWork = true
|
||||||
|
console.log(
|
||||||
|
pc.yellow(
|
||||||
|
`\n No meaningful work events for ${Math.round(
|
||||||
|
secondaryMeaningfulWorkTimeoutMs / 1000
|
||||||
|
)}s but session has active work - assuming in progress`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Track when first meaningful work was received
|
// Track when first meaningful work was received
|
||||||
if (firstWorkTimestamp === null) {
|
if (firstWorkTimestamp === null) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/config/ — Zod v4 Schema System
|
# src/config/ — Zod v4 Schema System
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export const AgentOverridesSchema = z.object({
|
|||||||
build: AgentOverrideConfigSchema.optional(),
|
build: AgentOverrideConfigSchema.optional(),
|
||||||
plan: AgentOverrideConfigSchema.optional(),
|
plan: AgentOverrideConfigSchema.optional(),
|
||||||
sisyphus: AgentOverrideConfigSchema.optional(),
|
sisyphus: AgentOverrideConfigSchema.optional(),
|
||||||
hephaestus: AgentOverrideConfigSchema.optional(),
|
hephaestus: AgentOverrideConfigSchema.extend({
|
||||||
|
allow_non_gpt_model: z.boolean().optional(),
|
||||||
|
}).optional(),
|
||||||
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
|
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
|
||||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||||
prometheus: AgentOverrideConfigSchema.optional(),
|
prometheus: AgentOverrideConfigSchema.optional(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const CategoryConfigSchema = z.object({
|
|||||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
tools: z.record(z.string(), z.boolean()).optional(),
|
||||||
prompt_append: z.string().optional(),
|
prompt_append: z.string().optional(),
|
||||||
|
max_prompt_tokens: z.number().int().positive().optional(),
|
||||||
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
|
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
|
||||||
is_unstable_agent: z.boolean().optional(),
|
is_unstable_agent: z.boolean().optional(),
|
||||||
/** Disable this category. Disabled categories are excluded from task delegation. */
|
/** Disable this category. Disabled categories are excluded from task delegation. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/ — 19 Feature Modules
|
# src/features/ — 19 Feature Modules
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/background-agent/ — Core Orchestration Engine
|
# src/features/background-agent/ — Core Orchestration Engine
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
|
|||||||
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
|
||||||
|
return (manager as unknown as { pendingNotifications: Map<string, string[]> }).pendingNotifications
|
||||||
|
}
|
||||||
|
|
||||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||||
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
||||||
}
|
}
|
||||||
@@ -1057,6 +1061,49 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
|||||||
|
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should queue notification when promptAsync aborts while parent is idle", async () => {
|
||||||
|
//#given
|
||||||
|
const promptMock = async () => {
|
||||||
|
const error = new Error("Request aborted while waiting for input")
|
||||||
|
error.name = "MessageAbortedError"
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: promptMock,
|
||||||
|
promptAsync: promptMock,
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-aborted-idle-queue",
|
||||||
|
sessionID: "session-child",
|
||||||
|
parentSessionID: "session-parent",
|
||||||
|
parentMessageID: "msg-parent",
|
||||||
|
description: "task idle queue",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||||
|
.notifyParentSession(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
|
||||||
|
expect(queuedNotifications).toHaveLength(1)
|
||||||
|
expect(queuedNotifications[0]).toContain("<system-reminder>")
|
||||||
|
expect(queuedNotifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||||
@@ -1105,6 +1152,29 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||||
|
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||||
|
// given
|
||||||
|
const manager = createBackgroundManager()
|
||||||
|
manager.queuePendingNotification("session-parent", "<system-reminder>queued-one</system-reminder>")
|
||||||
|
manager.queuePendingNotification("session-parent", "<system-reminder>queued-two</system-reminder>")
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "User prompt" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.injectPendingNotificationsIntoChatMessage(output, "session-parent")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output.parts[0].text).toContain("<system-reminder>queued-one</system-reminder>")
|
||||||
|
expect(output.parts[0].text).toContain("<system-reminder>queued-two</system-reminder>")
|
||||||
|
expect(output.parts[0].text).toContain("User prompt")
|
||||||
|
expect(getPendingNotifications(manager).get("session-parent")).toBeUndefined()
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function buildNotificationPromptBody(
|
function buildNotificationPromptBody(
|
||||||
task: BackgroundTask,
|
task: BackgroundTask,
|
||||||
currentMessage: CurrentMessage | null
|
currentMessage: CurrentMessage | null
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
private tasks: Map<string, BackgroundTask>
|
private tasks: Map<string, BackgroundTask>
|
||||||
private notifications: Map<string, BackgroundTask[]>
|
private notifications: Map<string, BackgroundTask[]>
|
||||||
|
private pendingNotifications: Map<string, string[]>
|
||||||
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
||||||
private client: OpencodeClient
|
private client: OpencodeClient
|
||||||
private directory: string
|
private directory: string
|
||||||
@@ -125,6 +126,7 @@ export class BackgroundManager {
|
|||||||
) {
|
) {
|
||||||
this.tasks = new Map()
|
this.tasks = new Map()
|
||||||
this.notifications = new Map()
|
this.notifications = new Map()
|
||||||
|
this.pendingNotifications = new Map()
|
||||||
this.pendingByParent = new Map()
|
this.pendingByParent = new Map()
|
||||||
this.client = ctx.client
|
this.client = ctx.client
|
||||||
this.directory = ctx.directory
|
this.directory = ctx.directory
|
||||||
@@ -917,6 +919,32 @@ export class BackgroundManager {
|
|||||||
this.notifications.delete(sessionID)
|
this.notifications.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queuePendingNotification(sessionID: string | undefined, notification: string): void {
|
||||||
|
if (!sessionID) return
|
||||||
|
const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
|
||||||
|
existingNotifications.push(notification)
|
||||||
|
this.pendingNotifications.set(sessionID, existingNotifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
|
||||||
|
const pendingNotifications = this.pendingNotifications.get(sessionID)
|
||||||
|
if (!pendingNotifications || pendingNotifications.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingNotifications.delete(sessionID)
|
||||||
|
const notificationContent = pendingNotifications.join("\n\n")
|
||||||
|
const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
|
||||||
|
|
||||||
|
if (firstTextPartIndex === -1) {
|
||||||
|
output.parts.unshift(createInternalAgentTextPart(notificationContent))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalText = output.parts[firstTextPartIndex].text ?? ""
|
||||||
|
output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a session has actual assistant/tool output before marking complete.
|
* Validates that a session has actual assistant/tool output before marking complete.
|
||||||
* Prevents premature completion when session.idle fires before agent responds.
|
* Prevents premature completion when session.idle fires before agent responds.
|
||||||
@@ -1340,6 +1368,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
parentSessionID: task.parentSessionID,
|
parentSessionID: task.parentSessionID,
|
||||||
})
|
})
|
||||||
|
this.queuePendingNotification(task.parentSessionID, notification)
|
||||||
} else {
|
} else {
|
||||||
log("[background-agent] Failed to send notification:", error)
|
log("[background-agent] Failed to send notification:", error)
|
||||||
}
|
}
|
||||||
@@ -1568,6 +1597,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
this.concurrencyManager.clear()
|
this.concurrencyManager.clear()
|
||||||
this.tasks.clear()
|
this.tasks.clear()
|
||||||
this.notifications.clear()
|
this.notifications.clear()
|
||||||
|
this.pendingNotifications.clear()
|
||||||
this.pendingByParent.clear()
|
this.pendingByParent.clear()
|
||||||
this.notificationQueueByParent.clear()
|
this.notificationQueueByParent.clear()
|
||||||
this.queuesByKey.clear()
|
this.queuesByKey.clear()
|
||||||
|
|||||||
@@ -269,6 +269,71 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should count space-indented unchecked checkbox", () => {
|
||||||
|
// given - plan file with a two-space indented checkbox
|
||||||
|
const planPath = join(TEST_DIR, "space-indented-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
- [ ] indented task
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(1)
|
||||||
|
expect(progress.completed).toBe(0)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should count tab-indented unchecked checkbox", () => {
|
||||||
|
// given - plan file with a tab-indented checkbox
|
||||||
|
const planPath = join(TEST_DIR, "tab-indented-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
- [ ] tab-indented task
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(1)
|
||||||
|
expect(progress.completed).toBe(0)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should count mixed top-level checked and indented unchecked checkboxes", () => {
|
||||||
|
// given - plan file with checked top-level and unchecked indented task
|
||||||
|
const planPath = join(TEST_DIR, "mixed-indented-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
- [x] top-level completed task
|
||||||
|
- [ ] nested unchecked task
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(2)
|
||||||
|
expect(progress.completed).toBe(1)
|
||||||
|
expect(progress.isComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should count space-indented completed checkbox", () => {
|
||||||
|
// given - plan file with a two-space indented completed checkbox
|
||||||
|
const planPath = join(TEST_DIR, "indented-completed-plan.md")
|
||||||
|
writeFileSync(planPath, `# Plan
|
||||||
|
- [x] indented completed task
|
||||||
|
`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progress.total).toBe(1)
|
||||||
|
expect(progress.completed).toBe(1)
|
||||||
|
expect(progress.isComplete).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("should return isComplete true when all checked", () => {
|
test("should return isComplete true when all checked", () => {
|
||||||
// given - all tasks completed
|
// given - all tasks completed
|
||||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
|||||||
const content = readFileSync(planPath, "utf-8")
|
const content = readFileSync(planPath, "utf-8")
|
||||||
|
|
||||||
// Match markdown checkboxes: - [ ] or - [x] or - [X]
|
// Match markdown checkboxes: - [ ] or - [x] or - [X]
|
||||||
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
|
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
|
||||||
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
|
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
|
||||||
|
|
||||||
const total = uncheckedMatches.length + checkedMatches.length
|
const total = uncheckedMatches.length + checkedMatches.length
|
||||||
const completed = checkedMatches.length
|
const completed = checkedMatches.length
|
||||||
@@ -150,7 +150,8 @@ export function getPlanName(planPath: string): string {
|
|||||||
export function createBoulderState(
|
export function createBoulderState(
|
||||||
planPath: string,
|
planPath: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
agent?: string
|
agent?: string,
|
||||||
|
worktreePath?: string,
|
||||||
): BoulderState {
|
): BoulderState {
|
||||||
return {
|
return {
|
||||||
active_plan: planPath,
|
active_plan: planPath,
|
||||||
@@ -158,5 +159,6 @@ export function createBoulderState(
|
|||||||
session_ids: [sessionId],
|
session_ids: [sessionId],
|
||||||
plan_name: getPlanName(planPath),
|
plan_name: getPlanName(planPath),
|
||||||
...(agent !== undefined ? { agent } : {}),
|
...(agent !== undefined ? { agent } : {}),
|
||||||
|
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface BoulderState {
|
|||||||
plan_name: string
|
plan_name: string
|
||||||
/** Agent type to use when resuming (e.g., 'atlas') */
|
/** Agent type to use when resuming (e.g., 'atlas') */
|
||||||
agent?: string
|
agent?: string
|
||||||
|
/** Absolute path to the git worktree root where work happens */
|
||||||
|
worktree_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanProgress {
|
export interface PlanProgress {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||||
|
|
||||||
|
## ARGUMENTS
|
||||||
|
|
||||||
|
- \`/start-work [plan-name] [--worktree <path>]\`
|
||||||
|
- \`plan-name\` (optional): name or partial match of the plan to start
|
||||||
|
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
|
||||||
|
- If specified and valid: hook pre-sets worktree_path in boulder.json
|
||||||
|
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
|
||||||
|
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
|
||||||
|
|
||||||
## WHAT TO DO
|
## WHAT TO DO
|
||||||
|
|
||||||
1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\`
|
1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\`
|
||||||
@@ -15,17 +24,24 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
|||||||
- If ONE plan: auto-select it
|
- If ONE plan: auto-select it
|
||||||
- If MULTIPLE plans: show list with timestamps, ask user to select
|
- If MULTIPLE plans: show list with timestamps, ask user to select
|
||||||
|
|
||||||
4. **Create/Update boulder.json**:
|
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
|
||||||
|
1. \`git worktree list --porcelain\` — see available worktrees
|
||||||
|
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
||||||
|
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
|
||||||
|
4. All work happens inside that worktree directory
|
||||||
|
|
||||||
|
5. **Create/Update boulder.json**:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
"active_plan": "/absolute/path/to/plan.md",
|
"active_plan": "/absolute/path/to/plan.md",
|
||||||
"started_at": "ISO_TIMESTAMP",
|
"started_at": "ISO_TIMESTAMP",
|
||||||
"session_ids": ["session_id_1", "session_id_2"],
|
"session_ids": ["session_id_1", "session_id_2"],
|
||||||
"plan_name": "plan-name"
|
"plan_name": "plan-name",
|
||||||
|
"worktree_path": "/absolute/path/to/git/worktree"
|
||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
5. **Read the plan file** and start executing tasks according to atlas workflow
|
6. **Read the plan file** and start executing tasks according to atlas workflow
|
||||||
|
|
||||||
## OUTPUT FORMAT
|
## OUTPUT FORMAT
|
||||||
|
|
||||||
@@ -49,6 +65,7 @@ Resuming Work Session
|
|||||||
Active Plan: {plan-name}
|
Active Plan: {plan-name}
|
||||||
Progress: {completed}/{total} tasks
|
Progress: {completed}/{total} tasks
|
||||||
Sessions: {count} (appending current session)
|
Sessions: {count} (appending current session)
|
||||||
|
Worktree: {worktree_path}
|
||||||
|
|
||||||
Reading plan and continuing from last incomplete task...
|
Reading plan and continuing from last incomplete task...
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -60,6 +77,7 @@ Starting Work Session
|
|||||||
Plan: {plan-name}
|
Plan: {plan-name}
|
||||||
Session ID: {session_id}
|
Session ID: {session_id}
|
||||||
Started: {timestamp}
|
Started: {timestamp}
|
||||||
|
Worktree: {worktree_path}
|
||||||
|
|
||||||
Reading plan and beginning execution...
|
Reading plan and beginning execution...
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -68,5 +86,6 @@ Reading plan and beginning execution...
|
|||||||
|
|
||||||
- The session_id is injected by the hook - use it directly
|
- The session_id is injected by the hook - use it directly
|
||||||
- Always update boulder.json BEFORE starting work
|
- Always update boulder.json BEFORE starting work
|
||||||
|
- Always set worktree_path in boulder.json before executing any tasks
|
||||||
- Read the FULL plan file before delegating any tasks
|
- Read the FULL plan file before delegating any tasks
|
||||||
- Follow atlas delegation protocols (7-section format)`
|
- Follow atlas delegation protocols (7-section format)`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/claude-tasks/ — Task Schema + Storage
|
# src/features/claude-tasks/ — Task Schema + Storage
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers
|
# src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
|
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/features/tmux-subagent/ — Tmux Pane Management
|
# src/features/tmux-subagent/ — Tmux Pane Management
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# src/hooks/ — 44 Lifecycle Hooks
|
# src/hooks/ — 46 Lifecycle Hooks
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
44 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(35) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
46 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||||
|
|
||||||
## HOOK TIERS
|
## HOOK TIERS
|
||||||
|
|
||||||
### Tier 1: Session Hooks (22) — `create-session-hooks.ts`
|
### Tier 1: Session Hooks (23) — `create-session-hooks.ts`
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
```
|
```
|
||||||
hooks/
|
hooks/
|
||||||
@@ -70,11 +70,12 @@ hooks/
|
|||||||
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
|
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
|
||||||
| taskResumeInfo | chat.message | Inject task context on resume |
|
| taskResumeInfo | chat.message | Inject task context on resume |
|
||||||
| anthropicEffort | chat.params | Adjust reasoning effort level |
|
| anthropicEffort | chat.params | Adjust reasoning effort level |
|
||||||
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
|
| modelFallback | chat.params | Provider-level model fallback on errors |
|
||||||
| sisyphusGptHephaestusReminder | chat.message | Toast warning when Sisyphus uses GPT model |
|
| noSisyphusGpt | chat.message | Block Sisyphus from using GPT models (toast warning) |
|
||||||
| taskReminder | tool.execute.after | Remind about task tools after 10 turns without usage |
|
| noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models |
|
||||||
|
| runtimeFallback | event | Auto-switch models on API provider errors |
|
||||||
|
|
||||||
### Tier 2: Tool Guard Hooks (9) — `create-tool-guard-hooks.ts`
|
### Tier 2: Tool Guard Hooks (10) — `create-tool-guard-hooks.ts`
|
||||||
|
|
||||||
| Hook | Event | Purpose |
|
| Hook | Event | Purpose |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
@@ -87,6 +88,7 @@ hooks/
|
|||||||
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
|
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
|
||||||
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
|
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
|
||||||
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
|
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
|
||||||
|
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
|
||||||
|
|
||||||
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`
|
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
|
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function getOrCreateRetryState(
|
|||||||
): RetryState {
|
): RetryState {
|
||||||
let state = autoCompactState.retryStateBySession.get(sessionID)
|
let state = autoCompactState.retryStateBySession.get(sessionID)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { attempt: 0, lastAttemptTime: 0 }
|
state = { attempt: 0, lastAttemptTime: 0, firstAttemptTime: 0 }
|
||||||
autoCompactState.retryStateBySession.set(sessionID, state)
|
autoCompactState.retryStateBySession.set(sessionID, state)
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
|
||||||
|
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
|
||||||
|
type TimeoutCall = {
|
||||||
|
delay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutoCompactState(): AutoCompactState {
|
||||||
|
return {
|
||||||
|
pendingCompact: new Set<string>(),
|
||||||
|
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||||
|
retryStateBySession: new Map<string, RetryState>(),
|
||||||
|
truncateStateBySession: new Map(),
|
||||||
|
emptyContentAttemptBySession: new Map(),
|
||||||
|
compactionInProgress: new Set<string>(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runSummarizeRetryStrategy", () => {
|
||||||
|
const sessionID = "ses_retry_timeout"
|
||||||
|
const directory = "/tmp"
|
||||||
|
let autoCompactState: AutoCompactState
|
||||||
|
|
||||||
|
const summarizeMock = mock(() => Promise.resolve())
|
||||||
|
const showToastMock = mock(() => Promise.resolve())
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
summarize: summarizeMock,
|
||||||
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
|
promptAsync: mock(() => Promise.resolve()),
|
||||||
|
revert: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: showToastMock,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
autoCompactState = createAutoCompactState()
|
||||||
|
summarizeMock.mockReset()
|
||||||
|
showToastMock.mockReset()
|
||||||
|
summarizeMock.mockResolvedValue(undefined)
|
||||||
|
showToastMock.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.setTimeout = originalSetTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
const originalSetTimeout = globalThis.setTimeout
|
||||||
|
|
||||||
|
test("stops retries when total summarize timeout is exceeded", async () => {
|
||||||
|
//#given
|
||||||
|
autoCompactState.pendingCompact.add(sessionID)
|
||||||
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
|
currentTokens: 250000,
|
||||||
|
maxTokens: 200000,
|
||||||
|
errorType: "token_limit_exceeded",
|
||||||
|
})
|
||||||
|
autoCompactState.retryStateBySession.set(sessionID, {
|
||||||
|
attempt: 1,
|
||||||
|
lastAttemptTime: Date.now(),
|
||||||
|
firstAttemptTime: Date.now() - 130000,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runSummarizeRetryStrategy({
|
||||||
|
sessionID,
|
||||||
|
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||||
|
autoCompactState,
|
||||||
|
client: client as never,
|
||||||
|
directory,
|
||||||
|
pluginConfig: {} as OhMyOpenCodeConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(summarizeMock).not.toHaveBeenCalled()
|
||||||
|
expect(autoCompactState.pendingCompact.has(sessionID)).toBe(false)
|
||||||
|
expect(autoCompactState.errorDataBySession.has(sessionID)).toBe(false)
|
||||||
|
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
title: "Auto Compact Timed Out",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps retry delay by remaining total timeout window", async () => {
|
||||||
|
//#given
|
||||||
|
const timeoutCalls: TimeoutCall[] = []
|
||||||
|
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
|
||||||
|
timeoutCalls.push({ delay: delay ?? 0 })
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||||
|
}) as typeof setTimeout
|
||||||
|
|
||||||
|
autoCompactState.retryStateBySession.set(sessionID, {
|
||||||
|
attempt: 1,
|
||||||
|
lastAttemptTime: Date.now(),
|
||||||
|
firstAttemptTime: Date.now() - 119700,
|
||||||
|
})
|
||||||
|
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runSummarizeRetryStrategy({
|
||||||
|
sessionID,
|
||||||
|
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||||
|
autoCompactState,
|
||||||
|
client: client as never,
|
||||||
|
directory,
|
||||||
|
pluginConfig: {} as OhMyOpenCodeConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(timeoutCalls.length).toBe(1)
|
||||||
|
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
|
||||||
|
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,8 @@ import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
|
|||||||
import { fixEmptyMessages } from "./empty-content-recovery"
|
import { fixEmptyMessages } from "./empty-content-recovery"
|
||||||
|
|
||||||
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
||||||
|
|
||||||
|
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
|
||||||
export async function runSummarizeRetryStrategy(params: {
|
export async function runSummarizeRetryStrategy(params: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
msg: Record<string, unknown>
|
msg: Record<string, unknown>
|
||||||
@@ -18,6 +20,27 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
messageIndex?: number
|
messageIndex?: number
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
|
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (retryState.firstAttemptTime === 0) {
|
||||||
|
retryState.firstAttemptTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedTimeMs = now - retryState.firstAttemptTime
|
||||||
|
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
|
||||||
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
|
await params.client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Auto Compact Timed Out",
|
||||||
|
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||||
|
variant: "error",
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (params.errorType?.includes("non-empty content")) {
|
if (params.errorType?.includes("non-empty content")) {
|
||||||
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
|
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
|
||||||
@@ -52,6 +75,7 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
|
|
||||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||||
retryState.attempt = 0
|
retryState.attempt = 0
|
||||||
|
retryState.firstAttemptTime = Date.now()
|
||||||
params.autoCompactState.truncateStateBySession.delete(params.sessionID)
|
params.autoCompactState.truncateStateBySession.delete(params.sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +116,26 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
|
||||||
|
if (remainingTimeMs <= 0) {
|
||||||
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
|
await params.client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Auto Compact Timed Out",
|
||||||
|
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||||
|
variant: "error",
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const delay =
|
const delay =
|
||||||
RETRY_CONFIG.initialDelayMs *
|
RETRY_CONFIG.initialDelayMs *
|
||||||
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
|
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
|
||||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void runSummarizeRetryStrategy(params)
|
void runSummarizeRetryStrategy(params)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface ParsedTokenLimitError {
|
|||||||
export interface RetryState {
|
export interface RetryState {
|
||||||
attempt: number
|
attempt: number
|
||||||
lastAttemptTime: number
|
lastAttemptTime: number
|
||||||
|
firstAttemptTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TruncateState {
|
export interface TruncateState {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/atlas/ — Master Boulder Orchestrator
|
# src/hooks/atlas/ — Master Boulder Orchestrator
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function injectBoulderContinuation(input: {
|
|||||||
remaining: number
|
remaining: number
|
||||||
total: number
|
total: number
|
||||||
agent?: string
|
agent?: string
|
||||||
|
worktreePath?: string
|
||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
sessionState: SessionState
|
sessionState: SessionState
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -24,6 +25,7 @@ export async function injectBoulderContinuation(input: {
|
|||||||
remaining,
|
remaining,
|
||||||
total,
|
total,
|
||||||
agent,
|
agent,
|
||||||
|
worktreePath,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
sessionState,
|
sessionState,
|
||||||
} = input
|
} = input
|
||||||
@@ -37,9 +39,11 @@ export async function injectBoulderContinuation(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
|
||||||
const prompt =
|
const prompt =
|
||||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
|
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||||
|
worktreeContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||||
@@ -62,6 +66,7 @@ export async function injectBoulderContinuation(input: {
|
|||||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sessionState.promptFailureCount += 1
|
sessionState.promptFailureCount += 1
|
||||||
|
sessionState.lastFailureAt = Date.now()
|
||||||
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: String(err),
|
error: String(err),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getLastAgentFromSession } from "./session-last-agent"
|
|||||||
import type { AtlasHookOptions, SessionState } from "./types"
|
import type { AtlasHookOptions, SessionState } from "./types"
|
||||||
|
|
||||||
const CONTINUATION_COOLDOWN_MS = 5000
|
const CONTINUATION_COOLDOWN_MS = 5000
|
||||||
|
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
export function createAtlasEventHandler(input: {
|
export function createAtlasEventHandler(input: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
@@ -53,6 +54,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = getState(sessionID)
|
const state = getState(sessionID)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
if (state.lastEventWasAbortError) {
|
if (state.lastEventWasAbortError) {
|
||||||
state.lastEventWasAbortError = false
|
state.lastEventWasAbortError = false
|
||||||
@@ -61,11 +63,18 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.promptFailureCount >= 2) {
|
if (state.promptFailureCount >= 2) {
|
||||||
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, {
|
const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
|
||||||
sessionID,
|
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
|
||||||
promptFailureCount: state.promptFailureCount,
|
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
|
||||||
})
|
sessionID,
|
||||||
return
|
promptFailureCount: state.promptFailureCount,
|
||||||
|
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.promptFailureCount = 0
|
||||||
|
state.lastFailureAt = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const backgroundManager = options?.backgroundManager
|
const backgroundManager = options?.backgroundManager
|
||||||
@@ -92,17 +101,15 @@ export function createAtlasEventHandler(input: {
|
|||||||
const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
|
const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
|
||||||
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
|
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
|
||||||
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
|
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
|
||||||
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
|
|
||||||
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
|
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
|
||||||
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
|
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
|
||||||
const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
|
const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
|
||||||
const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas
|
const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
|
||||||
if (!agentMatches) {
|
if (!agentMatches) {
|
||||||
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
|
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
lastAgent: lastAgent ?? "unknown",
|
lastAgent: lastAgent ?? "unknown",
|
||||||
requiredAgent,
|
requiredAgent,
|
||||||
boulderAgentExplicitlySet: boulderState.agent !== undefined,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,6 @@ export function createAtlasEventHandler(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -132,6 +138,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
remaining,
|
remaining,
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
agent: boulderState.agent,
|
agent: boulderState.agent,
|
||||||
|
worktreePath: boulderState.worktree_path,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
sessionState: state,
|
sessionState: state,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -933,8 +933,8 @@ describe("atlas hook", () => {
|
|||||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not inject when last agent does not match boulder agent", async () => {
|
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
|
||||||
// given - boulder state with incomplete plan, but last agent does NOT match
|
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
@@ -947,7 +947,7 @@ describe("atlas hook", () => {
|
|||||||
}
|
}
|
||||||
writeBoulderState(TEST_DIR, state)
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
// given - last agent is NOT the boulder agent
|
// given - last agent is sisyphus (typical state right after /start-work)
|
||||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||||
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
||||||
|
|
||||||
@@ -962,7 +962,39 @@ describe("atlas hook", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// then - should NOT call prompt because agent does not match
|
// then - should call prompt because sisyphus is always allowed for atlas boulders
|
||||||
|
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
|
||||||
|
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
|
||||||
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [MAIN_SESSION_ID],
|
||||||
|
plan_name: "test-plan",
|
||||||
|
agent: "atlas",
|
||||||
|
}
|
||||||
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
|
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||||
|
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
|
||||||
|
|
||||||
|
const mockInput = createMockPluginInput()
|
||||||
|
const hook = createAtlasHook(mockInput)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: MAIN_SESSION_ID },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
|
||||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1122,6 +1154,144 @@ describe("atlas hook", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should keep skipping continuation during 5-minute backoff after 2 consecutive failures", async () => {
|
||||||
|
//#given - boulder state with incomplete plan and prompt always fails
|
||||||
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [MAIN_SESSION_ID],
|
||||||
|
plan_name: "test-plan",
|
||||||
|
}
|
||||||
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
|
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||||
|
const mockInput = createMockPluginInput({ promptMock })
|
||||||
|
const hook = createAtlasHook(mockInput)
|
||||||
|
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let now = 0
|
||||||
|
Date.now = () => now
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when - third idle occurs inside 5-minute backoff window
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 60000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
//#then - third attempt should still be skipped
|
||||||
|
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should retry continuation after 5-minute backoff expires following 2 consecutive failures", async () => {
|
||||||
|
//#given - boulder state with incomplete plan and prompt always fails
|
||||||
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [MAIN_SESSION_ID],
|
||||||
|
plan_name: "test-plan",
|
||||||
|
}
|
||||||
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
|
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||||
|
const mockInput = createMockPluginInput({ promptMock })
|
||||||
|
const hook = createAtlasHook(mockInput)
|
||||||
|
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let now = 0
|
||||||
|
Date.now = () => now
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when - third idle occurs after 5+ minutes
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 300000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
//#then - third attempt should run after backoff expiration
|
||||||
|
expect(promptMock).toHaveBeenCalledTimes(3)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should reset prompt failure counter after successful retry beyond backoff window", async () => {
|
||||||
|
//#given - boulder state with incomplete plan and success on first retry after backoff
|
||||||
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [MAIN_SESSION_ID],
|
||||||
|
plan_name: "test-plan",
|
||||||
|
}
|
||||||
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
|
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
|
||||||
|
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||||
|
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||||
|
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
|
||||||
|
const mockInput = createMockPluginInput({ promptMock })
|
||||||
|
const hook = createAtlasHook(mockInput)
|
||||||
|
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let now = 0
|
||||||
|
Date.now = () => now
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when - fail twice, recover after backoff with success, then fail twice again
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 300000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
now += 6000
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
//#then - success retry resets counter, so two additional failures are allowed before skip
|
||||||
|
expect(promptMock).toHaveBeenCalledTimes(5)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("should reset continuation failure state on session.compacted event", async () => {
|
test("should reset continuation failure state on session.compacted event", async () => {
|
||||||
//#given - boulder state with incomplete plan and prompt always fails
|
//#given - boulder state with incomplete plan and prompt always fails
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export interface SessionState {
|
|||||||
lastEventWasAbortError?: boolean
|
lastEventWasAbortError?: boolean
|
||||||
lastContinuationInjectedAt?: number
|
lastContinuationInjectedAt?: number
|
||||||
promptFailureCount: number
|
promptFailureCount: number
|
||||||
|
lastFailureAt?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ interface EventInput {
|
|||||||
event: Event
|
event: Event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChatMessageInput {
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessageOutput {
|
||||||
|
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background notification hook - handles event routing to BackgroundManager.
|
* Background notification hook - handles event routing to BackgroundManager.
|
||||||
*
|
*
|
||||||
@@ -20,7 +28,15 @@ export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
|||||||
manager.handleEvent(event)
|
manager.handleEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatMessageHandler = async (
|
||||||
|
input: ChatMessageInput,
|
||||||
|
output: ChatMessageOutput,
|
||||||
|
): Promise<void> => {
|
||||||
|
manager.injectPendingNotificationsIntoChatMessage(output, input.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"chat.message": chatMessageHandler,
|
||||||
event: eventHandler,
|
event: eventHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/keyword-detector/ — Mode Keyword Injection
|
# src/hooks/keyword-detector/ — Mode Keyword Injection
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ const TOAST_MESSAGE = [
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
||||||
|
|
||||||
function showToast(ctx: PluginInput, sessionID: string): void {
|
type NoHephaestusNonGptHookOptions = {
|
||||||
|
allowNonGptModel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(ctx: PluginInput, sessionID: string, variant: "error" | "warning"): void {
|
||||||
ctx.client.tui.showToast({
|
ctx.client.tui.showToast({
|
||||||
body: {
|
body: {
|
||||||
title: TOAST_TITLE,
|
title: TOAST_TITLE,
|
||||||
message: TOAST_MESSAGE,
|
message: TOAST_MESSAGE,
|
||||||
variant: "error",
|
variant,
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -28,7 +32,10 @@ function showToast(ctx: PluginInput, sessionID: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNoHephaestusNonGptHook(ctx: PluginInput) {
|
export function createNoHephaestusNonGptHook(
|
||||||
|
ctx: PluginInput,
|
||||||
|
options?: NoHephaestusNonGptHookOptions,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
"chat.message": async (input: {
|
"chat.message": async (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -40,9 +47,13 @@ export function createNoHephaestusNonGptHook(ctx: PluginInput) {
|
|||||||
const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? ""
|
const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? ""
|
||||||
const agentKey = getAgentConfigKey(rawAgent)
|
const agentKey = getAgentConfigKey(rawAgent)
|
||||||
const modelID = input.model?.modelID
|
const modelID = input.model?.modelID
|
||||||
|
const allowNonGptModel = options?.allowNonGptModel === true
|
||||||
|
|
||||||
if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) {
|
if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) {
|
||||||
showToast(ctx, input.sessionID)
|
showToast(ctx, input.sessionID, allowNonGptModel ? "warning" : "error")
|
||||||
|
if (allowNonGptModel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
input.agent = SISYPHUS_DISPLAY
|
input.agent = SISYPHUS_DISPLAY
|
||||||
if (output?.message) {
|
if (output?.message) {
|
||||||
output.message.agent = SISYPHUS_DISPLAY
|
output.message.agent = SISYPHUS_DISPLAY
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, spyOn, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
|
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||||
@@ -8,7 +10,7 @@ const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
|||||||
|
|
||||||
function createOutput() {
|
function createOutput() {
|
||||||
return {
|
return {
|
||||||
message: {},
|
message: {} as { agent?: string; [key: string]: unknown },
|
||||||
parts: [],
|
parts: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +18,7 @@ function createOutput() {
|
|||||||
describe("no-hephaestus-non-gpt hook", () => {
|
describe("no-hephaestus-non-gpt hook", () => {
|
||||||
test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => {
|
test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => {
|
||||||
// given - hephaestus with claude model
|
// given - hephaestus with claude model
|
||||||
const showToast = spyOn({ fn: async () => ({}) }, "fn")
|
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||||
const hook = createNoHephaestusNonGptHook({
|
const hook = createNoHephaestusNonGptHook({
|
||||||
client: { tui: { showToast } },
|
client: { tui: { showToast } },
|
||||||
} as any)
|
} as any)
|
||||||
@@ -49,9 +51,38 @@ describe("no-hephaestus-non-gpt hook", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => {
|
||||||
|
// given - hephaestus with claude model and opt-out enabled
|
||||||
|
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||||
|
const hook = createNoHephaestusNonGptHook({
|
||||||
|
client: { tui: { showToast } },
|
||||||
|
} as any, {
|
||||||
|
allowNonGptModel: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = createOutput()
|
||||||
|
|
||||||
|
// when - chat.message runs
|
||||||
|
await hook["chat.message"]?.({
|
||||||
|
sessionID: "ses_opt_out",
|
||||||
|
agent: HEPHAESTUS_DISPLAY,
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}, output)
|
||||||
|
|
||||||
|
// then - warning toast is shown but agent is not switched
|
||||||
|
expect(showToast).toHaveBeenCalledTimes(1)
|
||||||
|
expect(output.message.agent).toBeUndefined()
|
||||||
|
expect(showToast.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
body: {
|
||||||
|
title: "NEVER Use Hephaestus with Non-GPT",
|
||||||
|
variant: "warning",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("does not show toast when hephaestus uses gpt model", async () => {
|
test("does not show toast when hephaestus uses gpt model", async () => {
|
||||||
// given - hephaestus with gpt model
|
// given - hephaestus with gpt model
|
||||||
const showToast = spyOn({ fn: async () => ({}) }, "fn")
|
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||||
const hook = createNoHephaestusNonGptHook({
|
const hook = createNoHephaestusNonGptHook({
|
||||||
client: { tui: { showToast } },
|
client: { tui: { showToast } },
|
||||||
} as any)
|
} as any)
|
||||||
@@ -72,7 +103,7 @@ describe("no-hephaestus-non-gpt hook", () => {
|
|||||||
|
|
||||||
test("does not show toast for non-hephaestus agent", async () => {
|
test("does not show toast for non-hephaestus agent", async () => {
|
||||||
// given - sisyphus with claude model (non-gpt)
|
// given - sisyphus with claude model (non-gpt)
|
||||||
const showToast = spyOn({ fn: async () => ({}) }, "fn")
|
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||||
const hook = createNoHephaestusNonGptHook({
|
const hook = createNoHephaestusNonGptHook({
|
||||||
client: { tui: { showToast } },
|
client: { tui: { showToast } },
|
||||||
} as any)
|
} as any)
|
||||||
@@ -95,7 +126,7 @@ describe("no-hephaestus-non-gpt hook", () => {
|
|||||||
// given - session agent saved as hephaestus
|
// given - session agent saved as hephaestus
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY)
|
updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY)
|
||||||
const showToast = spyOn({ fn: async () => ({}) }, "fn")
|
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||||
const hook = createNoHephaestusNonGptHook({
|
const hook = createNoHephaestusNonGptHook({
|
||||||
client: { tui: { showToast } },
|
client: { tui: { showToast } },
|
||||||
} as any)
|
} as any)
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ function createMockCtx() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupImmediateTimeouts(): () => void {
|
||||||
|
const originalSetTimeout = globalThis.setTimeout
|
||||||
|
const originalClearTimeout = globalThis.clearTimeout
|
||||||
|
|
||||||
|
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
|
||||||
|
callback(...args)
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||||
|
}) as typeof setTimeout
|
||||||
|
|
||||||
|
globalThis.clearTimeout = (() => {}) as typeof clearTimeout
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalThis.setTimeout = originalSetTimeout
|
||||||
|
globalThis.clearTimeout = originalClearTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("preemptive-compaction", () => {
|
describe("preemptive-compaction", () => {
|
||||||
let ctx: ReturnType<typeof createMockCtx>
|
let ctx: ReturnType<typeof createMockCtx>
|
||||||
|
|
||||||
@@ -63,7 +80,7 @@ describe("preemptive-compaction", () => {
|
|||||||
// #when tool.execute.after is called
|
// #when tool.execute.after is called
|
||||||
// #then session.messages() should NOT be called
|
// #then session.messages() should NOT be called
|
||||||
it("should use cached token info instead of fetching session.messages()", async () => {
|
it("should use cached token info instead of fetching session.messages()", async () => {
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
const sessionID = "ses_test1"
|
const sessionID = "ses_test1"
|
||||||
|
|
||||||
// Simulate message.updated with token info below threshold
|
// Simulate message.updated with token info below threshold
|
||||||
@@ -101,7 +118,7 @@ describe("preemptive-compaction", () => {
|
|||||||
// #when tool.execute.after is called
|
// #when tool.execute.after is called
|
||||||
// #then should skip without fetching
|
// #then should skip without fetching
|
||||||
it("should skip gracefully when no cached token info exists", async () => {
|
it("should skip gracefully when no cached token info exists", async () => {
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
|
|
||||||
const output = { title: "", output: "test", metadata: null }
|
const output = { title: "", output: "test", metadata: null }
|
||||||
await hook["tool.execute.after"](
|
await hook["tool.execute.after"](
|
||||||
@@ -116,7 +133,7 @@ describe("preemptive-compaction", () => {
|
|||||||
// #when tool.execute.after runs
|
// #when tool.execute.after runs
|
||||||
// #then should trigger summarize
|
// #then should trigger summarize
|
||||||
it("should trigger compaction when usage exceeds threshold", async () => {
|
it("should trigger compaction when usage exceeds threshold", async () => {
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
const sessionID = "ses_high"
|
const sessionID = "ses_high"
|
||||||
|
|
||||||
// 170K input + 10K cache = 180K → 90% of 200K
|
// 170K input + 10K cache = 180K → 90% of 200K
|
||||||
@@ -153,7 +170,7 @@ describe("preemptive-compaction", () => {
|
|||||||
|
|
||||||
it("should trigger compaction for google-vertex-anthropic provider", async () => {
|
it("should trigger compaction for google-vertex-anthropic provider", async () => {
|
||||||
//#given google-vertex-anthropic usage above threshold
|
//#given google-vertex-anthropic usage above threshold
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
const sessionID = "ses_vertex_anthropic_high"
|
const sessionID = "ses_vertex_anthropic_high"
|
||||||
|
|
||||||
await hook.event({
|
await hook.event({
|
||||||
@@ -191,7 +208,7 @@ describe("preemptive-compaction", () => {
|
|||||||
// #given session deleted
|
// #given session deleted
|
||||||
// #then cache should be cleaned up
|
// #then cache should be cleaned up
|
||||||
it("should clean up cache on session.deleted", async () => {
|
it("should clean up cache on session.deleted", async () => {
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
const sessionID = "ses_del"
|
const sessionID = "ses_del"
|
||||||
|
|
||||||
await hook.event({
|
await hook.event({
|
||||||
@@ -228,7 +245,7 @@ describe("preemptive-compaction", () => {
|
|||||||
|
|
||||||
it("should log summarize errors instead of swallowing them", async () => {
|
it("should log summarize errors instead of swallowing them", async () => {
|
||||||
//#given
|
//#given
|
||||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
const sessionID = "ses_log_error"
|
const sessionID = "ses_log_error"
|
||||||
const summarizeError = new Error("summarize failed")
|
const summarizeError = new Error("summarize failed")
|
||||||
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
|
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
|
||||||
@@ -343,4 +360,58 @@ describe("preemptive-compaction", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should clear in-progress lock when summarize times out", async () => {
|
||||||
|
//#given
|
||||||
|
const restoreTimeouts = setupImmediateTimeouts()
|
||||||
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
|
const sessionID = "ses_timeout"
|
||||||
|
|
||||||
|
ctx.client.session.summarize
|
||||||
|
.mockImplementationOnce(() => new Promise(() => {}))
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
sessionID,
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4-6",
|
||||||
|
finish: true,
|
||||||
|
tokens: {
|
||||||
|
input: 170000,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 10000, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](
|
||||||
|
{ tool: "bash", sessionID, callID: "call_timeout_1" },
|
||||||
|
{ title: "", output: "test", metadata: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
await hook["tool.execute.after"](
|
||||||
|
{ tool: "bash", sessionID, callID: "call_timeout_2" },
|
||||||
|
{ title: "", output: "test", metadata: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2)
|
||||||
|
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
||||||
|
sessionID,
|
||||||
|
error: expect.stringContaining("Compaction summarize timed out"),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
restoreTimeouts()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OhMyOpenCodeConfig } from "../config"
|
|||||||
|
|
||||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
const DEFAULT_ACTUAL_LIMIT = 200_000
|
||||||
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
type ModelCacheStateLike = {
|
type ModelCacheStateLike = {
|
||||||
anthropicContext1MEnabled: boolean
|
anthropicContext1MEnabled: boolean
|
||||||
@@ -31,6 +32,26 @@ interface CachedCompactionState {
|
|||||||
tokens: TokenInfo
|
tokens: TokenInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withTimeout<TValue>(
|
||||||
|
promise: Promise<TValue>,
|
||||||
|
timeoutMs: number,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<TValue> {
|
||||||
|
let timeoutID: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutID = setTimeout(() => {
|
||||||
|
reject(new Error(errorMessage))
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
|
if (timeoutID !== undefined) {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function isAnthropicProvider(providerID: string): boolean {
|
function isAnthropicProvider(providerID: string): boolean {
|
||||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
||||||
}
|
}
|
||||||
@@ -94,11 +115,15 @@ export function createPreemptiveCompactionHook(
|
|||||||
modelID
|
modelID
|
||||||
)
|
)
|
||||||
|
|
||||||
await ctx.client.session.summarize({
|
await withTimeout(
|
||||||
path: { id: sessionID },
|
ctx.client.session.summarize({
|
||||||
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
|
path: { id: sessionID },
|
||||||
query: { directory: ctx.directory },
|
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
|
||||||
})
|
query: { directory: ctx.directory },
|
||||||
|
}),
|
||||||
|
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
|
||||||
|
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
|
||||||
|
)
|
||||||
|
|
||||||
compactedSessions.add(sessionID)
|
compactedSessions.add(sessionID)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/ralph-loop/ — Self-Referential Dev Loop
|
# src/hooks/ralph-loop/ — Self-Referential Dev Loop
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ export async function continueIteration(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundState = options.loopState.setSessionID(newSessionID)
|
|
||||||
if (!boundState) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
|
|
||||||
previousSessionID: options.previousSessionID,
|
|
||||||
newSessionID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await injectContinuationPrompt(ctx, {
|
await injectContinuationPrompt(ctx, {
|
||||||
sessionID: newSessionID,
|
sessionID: newSessionID,
|
||||||
inheritFromSessionID: options.previousSessionID,
|
inheritFromSessionID: options.previousSessionID,
|
||||||
@@ -51,6 +42,16 @@ export async function continueIteration(
|
|||||||
})
|
})
|
||||||
|
|
||||||
await selectSessionInTui(ctx.client, newSessionID)
|
await selectSessionInTui(ctx.client, newSessionID)
|
||||||
|
|
||||||
|
const boundState = options.loopState.setSessionID(newSessionID)
|
||||||
|
if (!boundState) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
|
||||||
|
previousSessionID: options.previousSessionID,
|
||||||
|
newSessionID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
src/hooks/ralph-loop/reset-strategy-race-condition.test.ts
Normal file
113
src/hooks/ralph-loop/reset-strategy-race-condition.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRalphLoopHook } from "./index"
|
||||||
|
|
||||||
|
function createDeferred(): {
|
||||||
|
promise: Promise<void>
|
||||||
|
resolve: () => void
|
||||||
|
} {
|
||||||
|
let resolvePromise: (() => void) | null = null
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve: () => {
|
||||||
|
if (resolvePromise) {
|
||||||
|
resolvePromise()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(condition: () => boolean): Promise<void> {
|
||||||
|
for (let index = 0; index < 100; index++) {
|
||||||
|
if (condition()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Condition was not met in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ralph-loop reset strategy race condition", () => {
|
||||||
|
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
|
||||||
|
// given - reset strategy loop with blocked TUI session switch
|
||||||
|
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
||||||
|
const createSessionCalls: Array<{ parentID?: string }> = []
|
||||||
|
let selectSessionCalls = 0
|
||||||
|
const selectSessionDeferred = createDeferred()
|
||||||
|
|
||||||
|
const hook = createRalphLoopHook({
|
||||||
|
directory: process.cwd(),
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
prompt: async (options: {
|
||||||
|
path: { id: string }
|
||||||
|
body: { parts: Array<{ type: string; text: string }> }
|
||||||
|
}) => {
|
||||||
|
promptCalls.push({
|
||||||
|
sessionID: options.path.id,
|
||||||
|
text: options.body.parts[0].text,
|
||||||
|
})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
promptAsync: async (options: {
|
||||||
|
path: { id: string }
|
||||||
|
body: { parts: Array<{ type: string; text: string }> }
|
||||||
|
}) => {
|
||||||
|
promptCalls.push({
|
||||||
|
sessionID: options.path.id,
|
||||||
|
text: options.body.parts[0].text,
|
||||||
|
})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
create: async (options: {
|
||||||
|
body: { parentID?: string; title?: string }
|
||||||
|
query?: { directory?: string }
|
||||||
|
}) => {
|
||||||
|
createSessionCalls.push({ parentID: options.body.parentID })
|
||||||
|
return { data: { id: `new-session-${createSessionCalls.length}` } }
|
||||||
|
},
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => ({}),
|
||||||
|
selectSession: async () => {
|
||||||
|
selectSessionCalls += 1
|
||||||
|
await selectSessionDeferred.promise
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<typeof createRalphLoopHook>[0])
|
||||||
|
|
||||||
|
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
||||||
|
|
||||||
|
// when - first idle is in-flight and old session fires idle again before TUI switch resolves
|
||||||
|
const firstIdleEvent = hook.event({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitUntil(() => selectSessionCalls > 0)
|
||||||
|
|
||||||
|
const secondIdleEvent = hook.event({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitUntil(() => selectSessionCalls > 1)
|
||||||
|
|
||||||
|
selectSessionDeferred.resolve()
|
||||||
|
await Promise.all([firstIdleEvent, secondIdleEvent])
|
||||||
|
|
||||||
|
// then - second idle should not be skipped during reset transition
|
||||||
|
expect(createSessionCalls.length).toBe(2)
|
||||||
|
expect(promptCalls.length).toBe(2)
|
||||||
|
expect(hook.getState()?.iteration).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/rules-injector/ — Conditional Rules Injection
|
# src/hooks/rules-injector/ — Conditional Rules Injection
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:te
|
|||||||
const { createSessionNotification } = require("./session-notification")
|
const { createSessionNotification } = require("./session-notification")
|
||||||
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
|
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
|
||||||
const utils = require("./session-notification-utils")
|
const utils = require("./session-notification-utils")
|
||||||
|
const sender = require("./session-notification-sender")
|
||||||
|
|
||||||
describe("session-notification input-needed events", () => {
|
describe("session-notification input-needed events", () => {
|
||||||
let notificationCalls: string[]
|
let notificationCalls: string[]
|
||||||
@@ -37,6 +38,10 @@ describe("session-notification input-needed events", () => {
|
|||||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||||
|
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
|
||||||
|
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx: unknown, _platform: unknown, _title: unknown, message: string) => {
|
||||||
|
notificationCalls.push(message)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -47,7 +52,7 @@ describe("session-notification input-needed events", () => {
|
|||||||
test("sends question notification when question tool asks for input", async () => {
|
test("sends question notification when question tool asks for input", async () => {
|
||||||
const sessionID = "main-question"
|
const sessionID = "main-question"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
const hook = createSessionNotification(createMockPluginInput())
|
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
|
||||||
|
|
||||||
await hook({
|
await hook({
|
||||||
event: {
|
event: {
|
||||||
@@ -74,7 +79,7 @@ describe("session-notification input-needed events", () => {
|
|||||||
test("sends permission notification for permission events", async () => {
|
test("sends permission notification for permission events", async () => {
|
||||||
const sessionID = "main-permission"
|
const sessionID = "main-permission"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
const hook = createSessionNotification(createMockPluginInput())
|
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
|
||||||
|
|
||||||
await hook({
|
await hook({
|
||||||
event: {
|
event: {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||||
|
|
||||||
import { createSessionNotification } from "./session-notification"
|
import { createSessionNotification } from "./session-notification"
|
||||||
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
||||||
import * as utils from "./session-notification-utils"
|
import * as utils from "./session-notification-utils"
|
||||||
|
import * as sender from "./session-notification-sender"
|
||||||
|
|
||||||
describe("session-notification", () => {
|
describe("session-notification", () => {
|
||||||
let notificationCalls: string[]
|
let notificationCalls: string[]
|
||||||
@@ -40,6 +41,10 @@ describe("session-notification", () => {
|
|||||||
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
|
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
|
||||||
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
||||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||||
|
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
|
||||||
|
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx, _platform, _title, message) => {
|
||||||
|
notificationCalls.push(message)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -105,6 +110,7 @@ describe("session-notification", () => {
|
|||||||
const hook = createSessionNotification(createMockPluginInput(), {
|
const hook = createSessionNotification(createMockPluginInput(), {
|
||||||
idleConfirmationDelay: 10,
|
idleConfirmationDelay: 10,
|
||||||
skipIfIncompleteTodos: false,
|
skipIfIncompleteTodos: false,
|
||||||
|
enforceMainSessionFilter: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when - main session goes idle
|
// when - main session goes idle
|
||||||
@@ -332,6 +338,7 @@ describe("session-notification", () => {
|
|||||||
const hook = createSessionNotification(createMockPluginInput(), {
|
const hook = createSessionNotification(createMockPluginInput(), {
|
||||||
idleConfirmationDelay: 10,
|
idleConfirmationDelay: 10,
|
||||||
skipIfIncompleteTodos: false,
|
skipIfIncompleteTodos: false,
|
||||||
|
enforceMainSessionFilter: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// when - session goes idle twice
|
// when - session goes idle twice
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import {
|
|||||||
startBackgroundCheck,
|
startBackgroundCheck,
|
||||||
} from "./session-notification-utils"
|
} from "./session-notification-utils"
|
||||||
import {
|
import {
|
||||||
detectPlatform,
|
type Platform,
|
||||||
getDefaultSoundPath,
|
|
||||||
playSessionNotificationSound,
|
|
||||||
sendSessionNotification,
|
|
||||||
} from "./session-notification-sender"
|
} from "./session-notification-sender"
|
||||||
|
import * as sessionNotificationSender from "./session-notification-sender"
|
||||||
import { hasIncompleteTodos } from "./session-todo-status"
|
import { hasIncompleteTodos } from "./session-todo-status"
|
||||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||||
|
|
||||||
@@ -25,13 +23,14 @@ interface SessionNotificationConfig {
|
|||||||
skipIfIncompleteTodos?: boolean
|
skipIfIncompleteTodos?: boolean
|
||||||
/** Maximum number of sessions to track before cleanup (default: 100) */
|
/** Maximum number of sessions to track before cleanup (default: 100) */
|
||||||
maxTrackedSessions?: number
|
maxTrackedSessions?: number
|
||||||
|
enforceMainSessionFilter?: boolean
|
||||||
}
|
}
|
||||||
export function createSessionNotification(
|
export function createSessionNotification(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
config: SessionNotificationConfig = {}
|
config: SessionNotificationConfig = {}
|
||||||
) {
|
) {
|
||||||
const currentPlatform = detectPlatform()
|
const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
|
||||||
const defaultSoundPath = getDefaultSoundPath(currentPlatform)
|
const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
|
||||||
|
|
||||||
startBackgroundCheck(currentPlatform)
|
startBackgroundCheck(currentPlatform)
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ export function createSessionNotification(
|
|||||||
idleConfirmationDelay: 1500,
|
idleConfirmationDelay: 1500,
|
||||||
skipIfIncompleteTodos: true,
|
skipIfIncompleteTodos: true,
|
||||||
maxTrackedSessions: 100,
|
maxTrackedSessions: 100,
|
||||||
|
enforceMainSessionFilter: true,
|
||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@ export function createSessionNotification(
|
|||||||
platform: currentPlatform,
|
platform: currentPlatform,
|
||||||
config: mergedConfig,
|
config: mergedConfig,
|
||||||
hasIncompleteTodos,
|
hasIncompleteTodos,
|
||||||
send: sendSessionNotification,
|
send: sessionNotificationSender.sendSessionNotification,
|
||||||
playSound: playSessionNotificationSound,
|
playSound: sessionNotificationSender.playSessionNotificationSound,
|
||||||
})
|
})
|
||||||
|
|
||||||
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
|
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
|
||||||
@@ -81,8 +81,10 @@ export function createSessionNotification(
|
|||||||
const shouldNotifyForSession = (sessionID: string): boolean => {
|
const shouldNotifyForSession = (sessionID: string): boolean => {
|
||||||
if (subagentSessions.has(sessionID)) return false
|
if (subagentSessions.has(sessionID)) return false
|
||||||
|
|
||||||
const mainSessionID = getMainSessionID()
|
if (mergedConfig.enforceMainSessionFilter) {
|
||||||
if (mainSessionID && sessionID !== mainSessionID) return false
|
const mainSessionID = getMainSessionID()
|
||||||
|
if (mainSessionID && sessionID !== mainSessionID) return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -146,9 +148,14 @@ export function createSessionNotification(
|
|||||||
if (!shouldNotifyForSession(sessionID)) return
|
if (!shouldNotifyForSession(sessionID)) return
|
||||||
|
|
||||||
scheduler.markSessionActivity(sessionID)
|
scheduler.markSessionActivity(sessionID)
|
||||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
|
await sessionNotificationSender.sendSessionNotification(
|
||||||
|
ctx,
|
||||||
|
currentPlatform,
|
||||||
|
mergedConfig.title,
|
||||||
|
mergedConfig.permissionMessage,
|
||||||
|
)
|
||||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,9 +175,9 @@ export function createSessionNotification(
|
|||||||
? mergedConfig.permissionMessage
|
? mergedConfig.permissionMessage
|
||||||
: mergedConfig.questionMessage
|
: mergedConfig.questionMessage
|
||||||
|
|
||||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
|
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
|
||||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/session-recovery/ — Auto Session Error Recovery
|
# src/hooks/session-recovery/ — Auto Session Error Recovery
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import { createStartWorkHook } from "./index"
|
|||||||
import {
|
import {
|
||||||
writeBoulderState,
|
writeBoulderState,
|
||||||
clearBoulderState,
|
clearBoulderState,
|
||||||
|
readBoulderState,
|
||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import type { BoulderState } from "../../features/boulder-state"
|
import type { BoulderState } from "../../features/boulder-state"
|
||||||
import * as sessionState from "../../features/claude-code-session-state"
|
import * as sessionState from "../../features/claude-code-session-state"
|
||||||
|
import * as worktreeDetector from "./worktree-detector"
|
||||||
|
import * as worktreeDetector from "./worktree-detector"
|
||||||
|
|
||||||
describe("start-work hook", () => {
|
describe("start-work hook", () => {
|
||||||
let testDir: string
|
let testDir: string
|
||||||
@@ -402,4 +405,152 @@ describe("start-work hook", () => {
|
|||||||
updateSpy.mockRestore()
|
updateSpy.mockRestore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("worktree support", () => {
|
||||||
|
let detectSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
detectSpy = spyOn(worktreeDetector, "detectWorktreePath").mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
detectSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should inject model-decides instructions when no --worktree flag", async () => {
|
||||||
|
// given - single plan, no worktree flag
|
||||||
|
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||||
|
mkdirSync(plansDir, { recursive: true })
|
||||||
|
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-123" }, output)
|
||||||
|
|
||||||
|
// then - model-decides instructions should appear
|
||||||
|
expect(output.parts[0].text).toContain("Worktree Setup Required")
|
||||||
|
expect(output.parts[0].text).toContain("git worktree list --porcelain")
|
||||||
|
expect(output.parts[0].text).toContain("git worktree add")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should inject worktree path when --worktree flag is valid", async () => {
|
||||||
|
// given - single plan + valid worktree path
|
||||||
|
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||||
|
mkdirSync(plansDir, { recursive: true })
|
||||||
|
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
|
||||||
|
detectSpy.mockReturnValue("/validated/worktree")
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-123" }, output)
|
||||||
|
|
||||||
|
// then - validated path shown, no model-decides instructions
|
||||||
|
expect(output.parts[0].text).toContain("**Worktree**: /validated/worktree")
|
||||||
|
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should store worktree_path in boulder when --worktree is valid", async () => {
|
||||||
|
// given - plan + valid worktree
|
||||||
|
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||||
|
mkdirSync(plansDir, { recursive: true })
|
||||||
|
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
|
||||||
|
detectSpy.mockReturnValue("/valid/wt")
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-123" }, output)
|
||||||
|
|
||||||
|
// then - boulder.json has worktree_path
|
||||||
|
const state = readBoulderState(testDir)
|
||||||
|
expect(state?.worktree_path).toBe("/valid/wt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should NOT store worktree_path when --worktree path is invalid", async () => {
|
||||||
|
// given - plan + invalid worktree path (detectWorktreePath returns null)
|
||||||
|
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||||
|
mkdirSync(plansDir, { recursive: true })
|
||||||
|
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
|
||||||
|
// detectSpy already returns null by default
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-123" }, output)
|
||||||
|
|
||||||
|
// then - worktree_path absent, setup instructions present
|
||||||
|
const state = readBoulderState(testDir)
|
||||||
|
expect(state?.worktree_path).toBeUndefined()
|
||||||
|
expect(output.parts[0].text).toContain("needs setup")
|
||||||
|
expect(output.parts[0].text).toContain("git worktree add /nonexistent/wt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should update boulder worktree_path on resume when new --worktree given", async () => {
|
||||||
|
// given - existing boulder with old worktree, user provides new worktree
|
||||||
|
const planPath = join(testDir, "plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
|
||||||
|
const existingState: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-01T00:00:00Z",
|
||||||
|
session_ids: ["old-session"],
|
||||||
|
plan_name: "plan",
|
||||||
|
worktree_path: "/old/wt",
|
||||||
|
}
|
||||||
|
writeBoulderState(testDir, existingState)
|
||||||
|
detectSpy.mockReturnValue("/new/wt")
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-456" }, output)
|
||||||
|
|
||||||
|
// then - boulder reflects updated worktree and new session appended
|
||||||
|
const state = readBoulderState(testDir)
|
||||||
|
expect(state?.worktree_path).toBe("/new/wt")
|
||||||
|
expect(state?.session_ids).toContain("session-456")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should show existing worktree on resume when no --worktree flag", async () => {
|
||||||
|
// given - existing boulder already has worktree_path, no flag given
|
||||||
|
const planPath = join(testDir, "plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
|
||||||
|
const existingState: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-01T00:00:00Z",
|
||||||
|
session_ids: ["old-session"],
|
||||||
|
plan_name: "plan",
|
||||||
|
worktree_path: "/existing/wt",
|
||||||
|
}
|
||||||
|
writeBoulderState(testDir, existingState)
|
||||||
|
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"]({ sessionID: "session-789" }, output)
|
||||||
|
|
||||||
|
// then - shows existing worktree, no model-decides instructions
|
||||||
|
expect(output.parts[0].text).toContain("/existing/wt")
|
||||||
|
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export { HOOK_NAME, createStartWorkHook } from "./start-work-hook"
|
export { HOOK_NAME, createStartWorkHook } from "./start-work-hook"
|
||||||
|
export { detectWorktreePath } from "./worktree-detector"
|
||||||
|
export type { ParsedUserRequest } from "./parse-user-request"
|
||||||
|
export { parseUserRequest } from "./parse-user-request"
|
||||||
|
|||||||
78
src/hooks/start-work/parse-user-request.test.ts
Normal file
78
src/hooks/start-work/parse-user-request.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { parseUserRequest } from "./parse-user-request"
|
||||||
|
|
||||||
|
describe("parseUserRequest", () => {
|
||||||
|
describe("when no user-request tag", () => {
|
||||||
|
test("#given prompt without tag #when parsing #then returns nulls", () => {
|
||||||
|
const result = parseUserRequest("Just a regular message without any tags")
|
||||||
|
expect(result.planName).toBeNull()
|
||||||
|
expect(result.explicitWorktreePath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when user-request tag is empty", () => {
|
||||||
|
test("#given empty user-request tag #when parsing #then returns nulls", () => {
|
||||||
|
const result = parseUserRequest("<user-request> </user-request>")
|
||||||
|
expect(result.planName).toBeNull()
|
||||||
|
expect(result.explicitWorktreePath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when only plan name given", () => {
|
||||||
|
test("#given plan name without worktree flag #when parsing #then returns plan name with null worktree", () => {
|
||||||
|
const result = parseUserRequest("<session-context>\n<user-request>my-plan</user-request>\n</session-context>")
|
||||||
|
expect(result.planName).toBe("my-plan")
|
||||||
|
expect(result.explicitWorktreePath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when only --worktree flag given", () => {
|
||||||
|
test("#given --worktree with path only #when parsing #then returns worktree path with null plan", () => {
|
||||||
|
const result = parseUserRequest("<user-request>--worktree /home/user/repo-feat</user-request>")
|
||||||
|
expect(result.planName).toBeNull()
|
||||||
|
expect(result.explicitWorktreePath).toBe("/home/user/repo-feat")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when plan name and --worktree are both given", () => {
|
||||||
|
test("#given plan name before --worktree #when parsing #then returns both", () => {
|
||||||
|
const result = parseUserRequest("<user-request>my-plan --worktree /path/to/worktree</user-request>")
|
||||||
|
expect(result.planName).toBe("my-plan")
|
||||||
|
expect(result.explicitWorktreePath).toBe("/path/to/worktree")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given --worktree before plan name #when parsing #then returns both", () => {
|
||||||
|
const result = parseUserRequest("<user-request>--worktree /path/to/worktree my-plan</user-request>")
|
||||||
|
expect(result.planName).toBe("my-plan")
|
||||||
|
expect(result.explicitWorktreePath).toBe("/path/to/worktree")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when --worktree flag has no path", () => {
|
||||||
|
test("#given --worktree without path #when parsing #then worktree path is null", () => {
|
||||||
|
const result = parseUserRequest("<user-request>--worktree</user-request>")
|
||||||
|
expect(result.explicitWorktreePath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when ultrawork keywords are present", () => {
|
||||||
|
test("#given plan name with ultrawork keyword #when parsing #then strips keyword from plan name", () => {
|
||||||
|
const result = parseUserRequest("<user-request>my-plan ultrawork</user-request>")
|
||||||
|
expect(result.planName).toBe("my-plan")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given plan name with ulw keyword and worktree #when parsing #then strips ulw, preserves worktree", () => {
|
||||||
|
const result = parseUserRequest("<user-request>my-plan ulw --worktree /path/to/wt</user-request>")
|
||||||
|
expect(result.planName).toBe("my-plan")
|
||||||
|
expect(result.explicitWorktreePath).toBe("/path/to/wt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given only ultrawork keyword with worktree #when parsing #then plan name is null, worktree preserved", () => {
|
||||||
|
const result = parseUserRequest("<user-request>ultrawork --worktree /wt</user-request>")
|
||||||
|
expect(result.planName).toBeNull()
|
||||||
|
expect(result.explicitWorktreePath).toBe("/wt")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/hooks/start-work/parse-user-request.ts
Normal file
29
src/hooks/start-work/parse-user-request.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
||||||
|
const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/
|
||||||
|
|
||||||
|
export interface ParsedUserRequest {
|
||||||
|
planName: string | null
|
||||||
|
explicitWorktreePath: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserRequest(promptText: string): ParsedUserRequest {
|
||||||
|
const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
|
||||||
|
if (!match) return { planName: null, explicitWorktreePath: null }
|
||||||
|
|
||||||
|
let rawArg = match[1].trim()
|
||||||
|
if (!rawArg) return { planName: null, explicitWorktreePath: null }
|
||||||
|
|
||||||
|
const worktreeMatch = rawArg.match(WORKTREE_FLAG_PATTERN)
|
||||||
|
const explicitWorktreePath = worktreeMatch ? (worktreeMatch[1] ?? null) : null
|
||||||
|
|
||||||
|
if (worktreeMatch) {
|
||||||
|
rawArg = rawArg.replace(worktreeMatch[0], "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
planName: cleanedArg || null,
|
||||||
|
explicitWorktreePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { statSync } from "node:fs"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import {
|
import {
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
@@ -11,11 +12,11 @@ import {
|
|||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||||
|
import { detectWorktreePath } from "./worktree-detector"
|
||||||
|
import { parseUserRequest } from "./parse-user-request"
|
||||||
|
|
||||||
export const HOOK_NAME = "start-work" as const
|
export const HOOK_NAME = "start-work" as const
|
||||||
|
|
||||||
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
|
||||||
|
|
||||||
interface StartWorkHookInput {
|
interface StartWorkHookInput {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID?: string
|
messageID?: string
|
||||||
@@ -25,73 +26,76 @@ interface StartWorkHookOutput {
|
|||||||
parts: Array<{ type: string; text?: string }>
|
parts: Array<{ type: string; text?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUserRequestPlanName(promptText: string): string | null {
|
|
||||||
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
|
|
||||||
if (!userRequestMatch) return null
|
|
||||||
|
|
||||||
const rawArg = userRequestMatch[1].trim()
|
|
||||||
if (!rawArg) return null
|
|
||||||
|
|
||||||
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
|
||||||
return cleanedArg || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPlanByName(plans: string[], requestedName: string): string | null {
|
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||||
const lowerName = requestedName.toLowerCase()
|
const lowerName = requestedName.toLowerCase()
|
||||||
|
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
|
||||||
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
|
|
||||||
if (exactMatch) return exactMatch
|
if (exactMatch) return exactMatch
|
||||||
|
const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName))
|
||||||
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
|
|
||||||
return partialMatch || null
|
return partialMatch || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODEL_DECIDES_WORKTREE_BLOCK = `
|
||||||
|
## Worktree Setup Required
|
||||||
|
|
||||||
|
No worktree specified. Before starting work, you MUST choose or create one:
|
||||||
|
|
||||||
|
1. \`git worktree list --porcelain\` — list existing worktrees
|
||||||
|
2. Create if needed: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
||||||
|
3. Update \`.sisyphus/boulder.json\` — add \`"worktree_path": "<absolute-path>"\`
|
||||||
|
4. Work exclusively inside that worktree directory`
|
||||||
|
|
||||||
|
function resolveWorktreeContext(
|
||||||
|
explicitWorktreePath: string | null,
|
||||||
|
): { worktreePath: string | undefined; block: string } {
|
||||||
|
if (explicitWorktreePath === null) {
|
||||||
|
return { worktreePath: undefined, block: MODEL_DECIDES_WORKTREE_BLOCK }
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedPath = detectWorktreePath(explicitWorktreePath)
|
||||||
|
if (validatedPath) {
|
||||||
|
return { worktreePath: validatedPath, block: `\n**Worktree**: ${validatedPath}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
worktreePath: undefined,
|
||||||
|
block: `\n**Worktree** (needs setup): \`git worktree add ${explicitWorktreePath} <branch>\`, then add \`"worktree_path"\` to boulder.json`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createStartWorkHook(ctx: PluginInput) {
|
export function createStartWorkHook(ctx: PluginInput) {
|
||||||
return {
|
return {
|
||||||
"chat.message": async (
|
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
|
||||||
input: StartWorkHookInput,
|
|
||||||
output: StartWorkHookOutput
|
|
||||||
): Promise<void> => {
|
|
||||||
const parts = output.parts
|
const parts = output.parts
|
||||||
const promptText = parts
|
const promptText =
|
||||||
?.filter((p) => p.type === "text" && p.text)
|
parts
|
||||||
.map((p) => p.text)
|
?.filter((p) => p.type === "text" && p.text)
|
||||||
.join("\n")
|
.map((p) => p.text)
|
||||||
.trim() || ""
|
.join("\n")
|
||||||
|
.trim() || ""
|
||||||
|
|
||||||
// Only trigger on actual command execution (contains <session-context> tag)
|
if (!promptText.includes("<session-context>")) return
|
||||||
// NOT on description text like "Start Sisyphus work session from Prometheus plan"
|
|
||||||
const isStartWorkCommand = promptText.includes("<session-context>")
|
|
||||||
|
|
||||||
if (!isStartWorkCommand) {
|
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
||||||
return
|
updateSessionAgent(input.sessionID, "atlas")
|
||||||
}
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Processing start-work command`, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298
|
|
||||||
|
|
||||||
const existingState = readBoulderState(ctx.directory)
|
const existingState = readBoulderState(ctx.directory)
|
||||||
const sessionId = input.sessionID
|
const sessionId = input.sessionID
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
|
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
||||||
|
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
||||||
|
|
||||||
let contextInfo = ""
|
let contextInfo = ""
|
||||||
|
|
||||||
const explicitPlanName = extractUserRequestPlanName(promptText)
|
|
||||||
|
|
||||||
if (explicitPlanName) {
|
if (explicitPlanName) {
|
||||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
|
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
|
||||||
sessionID: input.sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const allPlans = findPrometheusPlans(ctx.directory)
|
const allPlans = findPrometheusPlans(ctx.directory)
|
||||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||||
|
|
||||||
if (matchedPlan) {
|
if (matchedPlan) {
|
||||||
const progress = getPlanProgress(matchedPlan)
|
const progress = getPlanProgress(matchedPlan)
|
||||||
|
|
||||||
if (progress.isComplete) {
|
if (progress.isComplete) {
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Plan Already Complete
|
## Plan Already Complete
|
||||||
@@ -99,12 +103,10 @@ export function createStartWorkHook(ctx: PluginInput) {
|
|||||||
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||||
} else {
|
} else {
|
||||||
if (existingState) {
|
if (existingState) clearBoulderState(ctx.directory)
|
||||||
clearBoulderState(ctx.directory)
|
const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
|
||||||
}
|
|
||||||
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
|
|
||||||
writeBoulderState(ctx.directory, newState)
|
writeBoulderState(ctx.directory, newState)
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Auto-Selected Plan
|
## Auto-Selected Plan
|
||||||
|
|
||||||
@@ -113,17 +115,20 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
|||||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||||
**Session ID**: ${sessionId}
|
**Session ID**: ${sessionId}
|
||||||
**Started**: ${timestamp}
|
**Started**: ${timestamp}
|
||||||
|
${worktreeBlock}
|
||||||
|
|
||||||
boulder.json has been created. Read the plan and begin execution.`
|
boulder.json has been created. Read the plan and begin execution.`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete)
|
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||||
if (incompletePlans.length > 0) {
|
if (incompletePlans.length > 0) {
|
||||||
const planList = incompletePlans.map((p, i) => {
|
const planList = incompletePlans
|
||||||
const prog = getPlanProgress(p)
|
.map((p, i) => {
|
||||||
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
const prog = getPlanProgress(p)
|
||||||
}).join("\n")
|
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Plan Not Found
|
## Plan Not Found
|
||||||
|
|
||||||
@@ -143,9 +148,25 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
|
|||||||
}
|
}
|
||||||
} else if (existingState) {
|
} else if (existingState) {
|
||||||
const progress = getPlanProgress(existingState.active_plan)
|
const progress = getPlanProgress(existingState.active_plan)
|
||||||
|
|
||||||
if (!progress.isComplete) {
|
if (!progress.isComplete) {
|
||||||
appendSessionId(ctx.directory, sessionId)
|
const effectiveWorktree = worktreePath ?? existingState.worktree_path
|
||||||
|
|
||||||
|
if (worktreePath !== undefined) {
|
||||||
|
const updatedSessions = existingState.session_ids.includes(sessionId)
|
||||||
|
? existingState.session_ids
|
||||||
|
: [...existingState.session_ids, sessionId]
|
||||||
|
writeBoulderState(ctx.directory, {
|
||||||
|
...existingState,
|
||||||
|
worktree_path: worktreePath,
|
||||||
|
session_ids: updatedSessions,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
appendSessionId(ctx.directory, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeDisplay = effectiveWorktree ? `\n**Worktree**: ${effectiveWorktree}` : worktreeBlock
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Active Work Session Found
|
## Active Work Session Found
|
||||||
|
|
||||||
@@ -155,6 +176,7 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
|
|||||||
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||||
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||||
**Started**: ${existingState.started_at}
|
**Started**: ${existingState.started_at}
|
||||||
|
${worktreeDisplay}
|
||||||
|
|
||||||
The current session (${sessionId}) has been added to session_ids.
|
The current session (${sessionId}) has been added to session_ids.
|
||||||
Read the plan file and continue from the first unchecked task.`
|
Read the plan file and continue from the first unchecked task.`
|
||||||
@@ -167,13 +189,15 @@ Looking for new plans...`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
|
if (
|
||||||
|
(!existingState && !explicitPlanName) ||
|
||||||
|
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
||||||
|
) {
|
||||||
const plans = findPrometheusPlans(ctx.directory)
|
const plans = findPrometheusPlans(ctx.directory)
|
||||||
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
|
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
|
||||||
|
|
||||||
if (plans.length === 0) {
|
if (plans.length === 0) {
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
|
|
||||||
## No Plans Found
|
## No Plans Found
|
||||||
|
|
||||||
No Prometheus plan files found at .sisyphus/plans/
|
No Prometheus plan files found at .sisyphus/plans/
|
||||||
@@ -187,7 +211,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
|
|||||||
} else if (incompletePlans.length === 1) {
|
} else if (incompletePlans.length === 1) {
|
||||||
const planPath = incompletePlans[0]
|
const planPath = incompletePlans[0]
|
||||||
const progress = getPlanProgress(planPath)
|
const progress = getPlanProgress(planPath)
|
||||||
const newState = createBoulderState(planPath, sessionId, "atlas")
|
const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
|
||||||
writeBoulderState(ctx.directory, newState)
|
writeBoulderState(ctx.directory, newState)
|
||||||
|
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
@@ -199,15 +223,17 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
|
|||||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||||
**Session ID**: ${sessionId}
|
**Session ID**: ${sessionId}
|
||||||
**Started**: ${timestamp}
|
**Started**: ${timestamp}
|
||||||
|
${worktreeBlock}
|
||||||
|
|
||||||
boulder.json has been created. Read the plan and begin execution.`
|
boulder.json has been created. Read the plan and begin execution.`
|
||||||
} else {
|
} else {
|
||||||
const planList = incompletePlans.map((p, i) => {
|
const planList = incompletePlans
|
||||||
const progress = getPlanProgress(p)
|
.map((p, i) => {
|
||||||
const stat = require("node:fs").statSync(p)
|
const progress = getPlanProgress(p)
|
||||||
const modified = new Date(stat.mtimeMs).toISOString()
|
const modified = new Date(statSync(p).mtimeMs).toISOString()
|
||||||
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
||||||
}).join("\n")
|
})
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
|
|
||||||
@@ -220,6 +246,7 @@ Session ID: ${sessionId}
|
|||||||
${planList}
|
${planList}
|
||||||
|
|
||||||
Ask the user which plan to work on. Present the options above and wait for their response.
|
Ask the user which plan to work on. Present the options above and wait for their response.
|
||||||
|
${worktreeBlock}
|
||||||
</system-reminder>`
|
</system-reminder>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,13 +256,14 @@ Ask the user which plan to work on. Present the options above and wait for their
|
|||||||
output.parts[idx].text = output.parts[idx].text
|
output.parts[idx].text = output.parts[idx].text
|
||||||
.replace(/\$SESSION_ID/g, sessionId)
|
.replace(/\$SESSION_ID/g, sessionId)
|
||||||
.replace(/\$TIMESTAMP/g, timestamp)
|
.replace(/\$TIMESTAMP/g, timestamp)
|
||||||
|
|
||||||
output.parts[idx].text += `\n\n---\n${contextInfo}`
|
output.parts[idx].text += `\n\n---\n${contextInfo}`
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Context injected`, {
|
log(`[${HOOK_NAME}] Context injected`, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
hasExistingState: !!existingState,
|
hasExistingState: !!existingState,
|
||||||
|
worktreePath,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/hooks/start-work/worktree-detector.test.ts
Normal file
79
src/hooks/start-work/worktree-detector.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
||||||
|
import * as childProcess from "node:child_process"
|
||||||
|
import { detectWorktreePath } from "./worktree-detector"
|
||||||
|
|
||||||
|
describe("detectWorktreePath", () => {
|
||||||
|
let execFileSyncSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
execFileSyncSpy = spyOn(childProcess, "execFileSync").mockImplementation(
|
||||||
|
((_file: string, _args: string[]) => "") as typeof childProcess.execFileSync,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
execFileSyncSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when directory is a valid git worktree", () => {
|
||||||
|
test("#given valid git dir #when detecting #then returns worktree root path", () => {
|
||||||
|
execFileSyncSpy.mockImplementation(
|
||||||
|
((_file: string, _args: string[]) => "/home/user/my-repo\n") as typeof childProcess.execFileSync,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = detectWorktreePath("/home/user/my-repo/src")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("/home/user/my-repo")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given git output with trailing newline #when detecting #then trims output", () => {
|
||||||
|
execFileSyncSpy.mockImplementation(
|
||||||
|
((_file: string, _args: string[]) => "/projects/worktree-a\n\n") as typeof childProcess.execFileSync,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = detectWorktreePath("/projects/worktree-a")
|
||||||
|
|
||||||
|
expect(result).toBe("/projects/worktree-a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given valid dir #when detecting #then calls git rev-parse with cwd", () => {
|
||||||
|
execFileSyncSpy.mockImplementation(
|
||||||
|
((_file: string, _args: string[]) => "/repo\n") as typeof childProcess.execFileSync,
|
||||||
|
)
|
||||||
|
|
||||||
|
detectWorktreePath("/repo/some/subdir")
|
||||||
|
|
||||||
|
expect(execFileSyncSpy).toHaveBeenCalledWith(
|
||||||
|
"git",
|
||||||
|
["rev-parse", "--show-toplevel"],
|
||||||
|
expect.objectContaining({ cwd: "/repo/some/subdir" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when directory is not a git worktree", () => {
|
||||||
|
test("#given non-git directory #when detecting #then returns null", () => {
|
||||||
|
execFileSyncSpy.mockImplementation((_file: string, _args: string[]) => {
|
||||||
|
throw new Error("not a git repository")
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = detectWorktreePath("/tmp/not-a-repo")
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given non-existent directory #when detecting #then returns null", () => {
|
||||||
|
execFileSyncSpy.mockImplementation((_file: string, _args: string[]) => {
|
||||||
|
throw new Error("ENOENT: no such file or directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = detectWorktreePath("/nonexistent/path")
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/hooks/start-work/worktree-detector.ts
Normal file
14
src/hooks/start-work/worktree-detector.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
|
||||||
|
export function detectWorktreePath(directory: string): string | null {
|
||||||
|
try {
|
||||||
|
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd: directory,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}).trim()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { detectThinkKeyword, extractPromptText } from "./detector"
|
import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||||
import { getHighVariant, getThinkingConfig, isAlreadyHighVariant } from "./switcher"
|
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
|
||||||
import type { ThinkModeInput, ThinkModeState } from "./types"
|
import type { ThinkModeState } from "./types"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
const thinkModeState = new Map<string, ThinkModeState>()
|
const thinkModeState = new Map<string, ThinkModeState>()
|
||||||
@@ -10,53 +10,24 @@ export function clearThinkModeState(sessionID: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createThinkModeHook() {
|
export function createThinkModeHook() {
|
||||||
function isDisabledThinkingConfig(config: Record<string, unknown>): boolean {
|
|
||||||
const thinkingConfig = config.thinking
|
|
||||||
if (
|
|
||||||
typeof thinkingConfig === "object" &&
|
|
||||||
thinkingConfig !== null &&
|
|
||||||
"type" in thinkingConfig &&
|
|
||||||
(thinkingConfig as { type?: string }).type === "disabled"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerOptions = config.providerOptions
|
|
||||||
if (typeof providerOptions !== "object" || providerOptions === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(providerOptions as Record<string, unknown>).some(
|
|
||||||
(providerConfig) => {
|
|
||||||
if (typeof providerConfig !== "object" || providerConfig === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerConfigMap = providerConfig as Record<string, unknown>
|
|
||||||
const extraBody = providerConfigMap.extra_body
|
|
||||||
if (typeof extraBody !== "object" || extraBody === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraBodyMap = extraBody as Record<string, unknown>
|
|
||||||
const extraThinking = extraBodyMap.thinking
|
|
||||||
return (
|
|
||||||
typeof extraThinking === "object" &&
|
|
||||||
extraThinking !== null &&
|
|
||||||
(extraThinking as { type?: string }).type === "disabled"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"chat.params": async (output: ThinkModeInput, sessionID: string): Promise<void> => {
|
"chat.message": async (
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
message: Record<string, unknown>
|
||||||
|
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
const promptText = extractPromptText(output.parts)
|
const promptText = extractPromptText(output.parts)
|
||||||
|
const sessionID = input.sessionID
|
||||||
|
|
||||||
const state: ThinkModeState = {
|
const state: ThinkModeState = {
|
||||||
requested: false,
|
requested: false,
|
||||||
modelSwitched: false,
|
modelSwitched: false,
|
||||||
thinkingConfigInjected: false,
|
variantSet: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!detectThinkKeyword(promptText)) {
|
if (!detectThinkKeyword(promptText)) {
|
||||||
@@ -66,7 +37,12 @@ export function createThinkModeHook() {
|
|||||||
|
|
||||||
state.requested = true
|
state.requested = true
|
||||||
|
|
||||||
const currentModel = output.message.model
|
if (typeof output.message.variant === "string") {
|
||||||
|
thinkModeState.set(sessionID, state)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentModel = input.model
|
||||||
if (!currentModel) {
|
if (!currentModel) {
|
||||||
thinkModeState.set(sessionID, state)
|
thinkModeState.set(sessionID, state)
|
||||||
return
|
return
|
||||||
@@ -81,14 +57,15 @@ export function createThinkModeHook() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const highVariant = getHighVariant(currentModel.modelID)
|
const highVariant = getHighVariant(currentModel.modelID)
|
||||||
const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID)
|
|
||||||
|
|
||||||
if (highVariant) {
|
if (highVariant) {
|
||||||
output.message.model = {
|
output.message.model = {
|
||||||
providerID: currentModel.providerID,
|
providerID: currentModel.providerID,
|
||||||
modelID: highVariant,
|
modelID: highVariant,
|
||||||
}
|
}
|
||||||
|
output.message.variant = "high"
|
||||||
state.modelSwitched = true
|
state.modelSwitched = true
|
||||||
|
state.variantSet = true
|
||||||
log("Think mode: model switched to high variant", {
|
log("Think mode: model switched to high variant", {
|
||||||
sessionID,
|
sessionID,
|
||||||
from: currentModel.modelID,
|
from: currentModel.modelID,
|
||||||
@@ -96,42 +73,6 @@ export function createThinkModeHook() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thinkingConfig) {
|
|
||||||
const messageData = output.message as Record<string, unknown>
|
|
||||||
const agentThinking = messageData.thinking as { type?: string } | undefined
|
|
||||||
const agentProviderOptions = messageData.providerOptions
|
|
||||||
|
|
||||||
const agentDisabledThinking = agentThinking?.type === "disabled"
|
|
||||||
const agentHasCustomProviderOptions = Boolean(agentProviderOptions)
|
|
||||||
|
|
||||||
if (agentDisabledThinking) {
|
|
||||||
log("Think mode: skipping - agent has thinking disabled", {
|
|
||||||
sessionID,
|
|
||||||
provider: currentModel.providerID,
|
|
||||||
})
|
|
||||||
} else if (agentHasCustomProviderOptions) {
|
|
||||||
log("Think mode: skipping - agent has custom providerOptions", {
|
|
||||||
sessionID,
|
|
||||||
provider: currentModel.providerID,
|
|
||||||
})
|
|
||||||
} else if (
|
|
||||||
!isDisabledThinkingConfig(thinkingConfig as Record<string, unknown>)
|
|
||||||
) {
|
|
||||||
Object.assign(output.message, thinkingConfig)
|
|
||||||
state.thinkingConfigInjected = true
|
|
||||||
log("Think mode: thinking config injected", {
|
|
||||||
sessionID,
|
|
||||||
provider: currentModel.providerID,
|
|
||||||
config: thinkingConfig,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log("Think mode: skipping disabled thinking config", {
|
|
||||||
sessionID,
|
|
||||||
provider: currentModel.providerID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thinkModeState.set(sessionID, state)
|
thinkModeState.set(sessionID, state)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,452 +1,155 @@
|
|||||||
import { describe, expect, it, beforeEach } from "bun:test"
|
import { beforeEach, describe, expect, it } from "bun:test"
|
||||||
import type { ThinkModeInput } from "./types"
|
|
||||||
|
|
||||||
const { createThinkModeHook, clearThinkModeState } = await import("./index")
|
const { clearThinkModeState, createThinkModeHook } = await import("./index")
|
||||||
|
|
||||||
|
type ThinkModeHookInput = {
|
||||||
|
sessionID: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThinkModeHookOutput = {
|
||||||
|
message: Record<string, unknown>
|
||||||
|
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHookInput(args: {
|
||||||
|
sessionID?: string
|
||||||
|
providerID?: string
|
||||||
|
modelID?: string
|
||||||
|
}): ThinkModeHookInput {
|
||||||
|
const { sessionID = "test-session-id", providerID, modelID } = args
|
||||||
|
|
||||||
|
if (!providerID || !modelID) {
|
||||||
|
return { sessionID }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a mock ThinkModeInput for testing
|
|
||||||
*/
|
|
||||||
function createMockInput(
|
|
||||||
providerID: string,
|
|
||||||
modelID: string,
|
|
||||||
promptText: string
|
|
||||||
): ThinkModeInput {
|
|
||||||
return {
|
return {
|
||||||
parts: [{ type: "text", text: promptText }],
|
sessionID,
|
||||||
message: {
|
model: { providerID, modelID },
|
||||||
model: {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function createHookOutput(promptText: string, variant?: string): ThinkModeHookOutput {
|
||||||
* Type helper for accessing dynamically injected properties on message
|
return {
|
||||||
*/
|
message: variant ? { variant } : {},
|
||||||
type MessageWithInjectedProps = Record<string, unknown>
|
parts: [{ type: "text", text: promptText }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("createThinkModeHook integration", () => {
|
describe("createThinkModeHook", () => {
|
||||||
const sessionID = "test-session-id"
|
const sessionID = "test-session-id"
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearThinkModeState(sessionID)
|
clearThinkModeState(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("GitHub Copilot provider integration", () => {
|
it("sets high variant and switches model when think keyword is present", async () => {
|
||||||
describe("Claude models", () => {
|
// given
|
||||||
it("should activate thinking mode for github-copilot Claude with think keyword", async () => {
|
const hook = createThinkModeHook()
|
||||||
// given a github-copilot Claude model and prompt with "think" keyword
|
const input = createHookInput({
|
||||||
const hook = createThinkModeHook()
|
sessionID,
|
||||||
const input = createMockInput(
|
providerID: "github-copilot",
|
||||||
"github-copilot",
|
modelID: "claude-opus-4-6",
|
||||||
"claude-opus-4-6",
|
|
||||||
"Please think deeply about this problem"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant and inject thinking config
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
|
||||||
expect(message.thinking).toBeDefined()
|
|
||||||
expect((message.thinking as Record<string, unknown>)?.type).toBe(
|
|
||||||
"enabled"
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
(message.thinking as Record<string, unknown>)?.budgetTokens
|
|
||||||
).toBe(64000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle github-copilot Claude with dots in version", async () => {
|
|
||||||
// given a github-copilot Claude model with dot format (claude-opus-4.6)
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"claude-opus-4.6",
|
|
||||||
"ultrathink mode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant (hyphen format)
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
|
||||||
expect(message.thinking).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle github-copilot Claude Sonnet", async () => {
|
|
||||||
// given a github-copilot Claude Sonnet model
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"claude-sonnet-4-6",
|
|
||||||
"think about this"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
|
|
||||||
expect(message.thinking).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
const output = createHookOutput("Please think deeply about this")
|
||||||
|
|
||||||
describe("Gemini models", () => {
|
// when
|
||||||
it("should activate thinking mode for github-copilot Gemini Pro", async () => {
|
await hook["chat.message"](input, output)
|
||||||
// given a github-copilot Gemini Pro model
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"gemini-3-pro",
|
|
||||||
"think about this"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
// then
|
||||||
await hook["chat.params"](input, sessionID)
|
expect(output.message.variant).toBe("high")
|
||||||
|
expect(output.message.model).toEqual({
|
||||||
// then should upgrade to high variant and inject google thinking config
|
providerID: "github-copilot",
|
||||||
const message = input.message as MessageWithInjectedProps
|
modelID: "claude-opus-4-6-high",
|
||||||
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
|
|
||||||
expect(message.providerOptions).toBeDefined()
|
|
||||||
const googleOptions = (
|
|
||||||
message.providerOptions as Record<string, unknown>
|
|
||||||
)?.google as Record<string, unknown>
|
|
||||||
expect(googleOptions?.thinkingConfig).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should activate thinking mode for github-copilot Gemini Flash", async () => {
|
|
||||||
// given a github-copilot Gemini Flash model
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"gemini-3-flash",
|
|
||||||
"ultrathink"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gemini-3-flash-high")
|
|
||||||
expect(message.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("GPT models", () => {
|
|
||||||
it("should activate thinking mode for github-copilot GPT-5.2", async () => {
|
|
||||||
// given a github-copilot GPT-5.2 model
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"gpt-5.2",
|
|
||||||
"please think"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant and inject openai thinking config
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gpt-5-2-high")
|
|
||||||
expect(message.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should activate thinking mode for github-copilot GPT-5", async () => {
|
|
||||||
// given a github-copilot GPT-5 model
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput("github-copilot", "gpt-5", "think deeply")
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should upgrade to high variant
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gpt-5-high")
|
|
||||||
expect(message.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("No think keyword", () => {
|
|
||||||
it("should NOT activate for github-copilot without think keyword", async () => {
|
|
||||||
// given a prompt without any think keyword
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"claude-opus-4-6",
|
|
||||||
"Just do this task"
|
|
||||||
)
|
|
||||||
const originalModelID = input.message.model?.modelID
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should NOT change model or inject config
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe(originalModelID)
|
|
||||||
expect(message.thinking).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Backwards compatibility with direct providers", () => {
|
it("supports dotted model IDs by switching to normalized high variant", async () => {
|
||||||
it("should still work for direct anthropic provider", async () => {
|
// given
|
||||||
// given direct anthropic provider
|
const hook = createThinkModeHook()
|
||||||
const hook = createThinkModeHook()
|
const input = createHookInput({
|
||||||
const input = createMockInput(
|
sessionID,
|
||||||
"anthropic",
|
providerID: "github-copilot",
|
||||||
"claude-sonnet-4-6",
|
modelID: "gpt-5.2",
|
||||||
"think about this"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should work as before
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
|
|
||||||
expect(message.thinking).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
const output = createHookOutput("ultrathink about this")
|
||||||
|
|
||||||
it("should work for direct google-vertex-anthropic provider", async () => {
|
// when
|
||||||
//#given direct google-vertex-anthropic provider
|
await hook["chat.message"](input, output)
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"google-vertex-anthropic",
|
|
||||||
"claude-opus-4-6",
|
|
||||||
"think deeply"
|
|
||||||
)
|
|
||||||
|
|
||||||
//#when the chat.params hook is called
|
// then
|
||||||
await hook["chat.params"](input, sessionID)
|
expect(output.message.variant).toBe("high")
|
||||||
|
expect(output.message.model).toEqual({
|
||||||
//#then should upgrade model and inject Claude thinking config
|
providerID: "github-copilot",
|
||||||
const message = input.message as MessageWithInjectedProps
|
modelID: "gpt-5-2-high",
|
||||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
|
||||||
expect(message.thinking).toBeDefined()
|
|
||||||
expect((message.thinking as Record<string, unknown>)?.budgetTokens).toBe(
|
|
||||||
64000
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for direct google provider", async () => {
|
|
||||||
// given direct google provider
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"google",
|
|
||||||
"gemini-3-pro",
|
|
||||||
"think about this"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should work as before
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
|
|
||||||
expect(message.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for direct openai provider", async () => {
|
|
||||||
// given direct openai provider
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput("openai", "gpt-5", "think about this")
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should work
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gpt-5-high")
|
|
||||||
expect(message.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for amazon-bedrock provider", async () => {
|
|
||||||
// given amazon-bedrock provider
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"amazon-bedrock",
|
|
||||||
"claude-sonnet-4-6",
|
|
||||||
"think"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should inject bedrock thinking config
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
|
|
||||||
expect(message.reasoningConfig).toBeDefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Already-high variants", () => {
|
it("skips when message variant is already set", async () => {
|
||||||
it("should NOT re-upgrade already-high variants", async () => {
|
// given
|
||||||
// given an already-high variant model
|
const hook = createThinkModeHook()
|
||||||
const hook = createThinkModeHook()
|
const input = createHookInput({
|
||||||
const input = createMockInput(
|
sessionID,
|
||||||
"github-copilot",
|
providerID: "github-copilot",
|
||||||
"claude-opus-4-6-high",
|
modelID: "claude-sonnet-4-6",
|
||||||
"think deeply"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should NOT modify the model (already high)
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
|
||||||
// No additional thinking config should be injected
|
|
||||||
expect(message.thinking).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
const output = createHookOutput("think through this", "max")
|
||||||
|
|
||||||
it("should NOT re-upgrade already-high GPT variants", async () => {
|
// when
|
||||||
// given an already-high GPT variant
|
await hook["chat.message"](input, output)
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput(
|
|
||||||
"github-copilot",
|
|
||||||
"gpt-5.2-high",
|
|
||||||
"ultrathink"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
// then
|
||||||
await hook["chat.params"](input, sessionID)
|
expect(output.message.variant).toBe("max")
|
||||||
|
expect(output.message.model).toBeUndefined()
|
||||||
// then should NOT modify the model
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(input.message.model?.modelID).toBe("gpt-5.2-high")
|
|
||||||
expect(message.reasoning_effort).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Unknown models", () => {
|
it("does nothing when think keyword is absent", async () => {
|
||||||
it("should not crash for unknown models via github-copilot", async () => {
|
// given
|
||||||
// given an unknown model type
|
const hook = createThinkModeHook()
|
||||||
const hook = createThinkModeHook()
|
const input = createHookInput({
|
||||||
const input = createMockInput(
|
sessionID,
|
||||||
"github-copilot",
|
providerID: "google",
|
||||||
"llama-3-70b",
|
modelID: "gemini-3-pro",
|
||||||
"think about this"
|
|
||||||
)
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should not crash and model should remain unchanged
|
|
||||||
expect(input.message.model?.modelID).toBe("llama-3-70b")
|
|
||||||
})
|
})
|
||||||
|
const output = createHookOutput("Please solve this directly")
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"](input, output)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output.message.variant).toBeUndefined()
|
||||||
|
expect(output.message.model).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Edge cases", () => {
|
it("does not modify already-high models", async () => {
|
||||||
it("should handle missing model gracefully", async () => {
|
// given
|
||||||
// given input without a model
|
const hook = createThinkModeHook()
|
||||||
const hook = createThinkModeHook()
|
const input = createHookInput({
|
||||||
const input: ThinkModeInput = {
|
sessionID,
|
||||||
parts: [{ type: "text", text: "think about this" }],
|
providerID: "openai",
|
||||||
message: {},
|
modelID: "gpt-5-high",
|
||||||
}
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
// then should not crash
|
|
||||||
await expect(
|
|
||||||
hook["chat.params"](input, sessionID)
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
const output = createHookOutput("think deeply")
|
||||||
|
|
||||||
it("should handle empty prompt gracefully", async () => {
|
// when
|
||||||
// given empty prompt
|
await hook["chat.message"](input, output)
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput("github-copilot", "claude-opus-4-6", "")
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
// then
|
||||||
await hook["chat.params"](input, sessionID)
|
expect(output.message.variant).toBeUndefined()
|
||||||
|
expect(output.message.model).toBeUndefined()
|
||||||
// then should not upgrade (no think keyword)
|
|
||||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Agent-level thinking configuration respect", () => {
|
it("handles missing input model without crashing", async () => {
|
||||||
it("should omit Z.ai GLM disabled thinking config", async () => {
|
// given
|
||||||
//#given a Z.ai GLM model with think prompt
|
const hook = createThinkModeHook()
|
||||||
const hook = createThinkModeHook()
|
const input = createHookInput({ sessionID })
|
||||||
const input = createMockInput(
|
const output = createHookOutput("think about this")
|
||||||
"zai-coding-plan",
|
|
||||||
"glm-5",
|
|
||||||
"ultrathink mode"
|
|
||||||
)
|
|
||||||
|
|
||||||
//#when think mode resolves Z.ai thinking configuration
|
// when
|
||||||
await hook["chat.params"](input, sessionID)
|
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
|
||||||
|
|
||||||
//#then thinking config should be omitted from request
|
// then
|
||||||
const message = input.message as MessageWithInjectedProps
|
expect(output.message.variant).toBeUndefined()
|
||||||
expect(input.message.model?.modelID).toBe("glm-5")
|
expect(output.message.model).toBeUndefined()
|
||||||
expect(message.thinking).toBeUndefined()
|
|
||||||
expect(message.providerOptions).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should NOT inject thinking config when agent has thinking disabled", async () => {
|
|
||||||
// given agent with thinking explicitly disabled
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input: ThinkModeInput = {
|
|
||||||
parts: [{ type: "text", text: "ultrathink deeply" }],
|
|
||||||
message: {
|
|
||||||
model: { providerID: "google", modelID: "gemini-3-pro" },
|
|
||||||
thinking: { type: "disabled" },
|
|
||||||
} as ThinkModeInput["message"],
|
|
||||||
}
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should NOT override agent's thinking disabled setting
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect((message.thinking as { type: string }).type).toBe("disabled")
|
|
||||||
expect(message.providerOptions).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should NOT inject thinking config when agent has custom providerOptions", async () => {
|
|
||||||
// given agent with custom providerOptions
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input: ThinkModeInput = {
|
|
||||||
parts: [{ type: "text", text: "ultrathink" }],
|
|
||||||
message: {
|
|
||||||
model: { providerID: "google", modelID: "gemini-3-flash" },
|
|
||||||
providerOptions: {
|
|
||||||
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
||||||
},
|
|
||||||
} as ThinkModeInput["message"],
|
|
||||||
}
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should NOT override agent's providerOptions
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
const providerOpts = message.providerOptions as Record<string, unknown>
|
|
||||||
expect((providerOpts.google as Record<string, unknown>).thinkingConfig).toEqual({
|
|
||||||
thinkingBudget: 0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still inject thinking config when agent has no thinking override", async () => {
|
|
||||||
// given agent without thinking override
|
|
||||||
const hook = createThinkModeHook()
|
|
||||||
const input = createMockInput("google", "gemini-3-pro", "ultrathink")
|
|
||||||
|
|
||||||
// when the chat.params hook is called
|
|
||||||
await hook["chat.params"](input, sessionID)
|
|
||||||
|
|
||||||
// then should inject thinking config as normal
|
|
||||||
const message = input.message as MessageWithInjectedProps
|
|
||||||
expect(message.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,128 +1,10 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
import { describe, expect, it } from "bun:test"
|
||||||
import {
|
import {
|
||||||
getHighVariant,
|
getHighVariant,
|
||||||
getThinkingConfig,
|
|
||||||
isAlreadyHighVariant,
|
isAlreadyHighVariant,
|
||||||
THINKING_CONFIGS,
|
|
||||||
} from "./switcher"
|
} from "./switcher"
|
||||||
|
|
||||||
describe("think-mode switcher", () => {
|
describe("think-mode switcher", () => {
|
||||||
describe("GitHub Copilot provider support", () => {
|
|
||||||
describe("Claude models via github-copilot", () => {
|
|
||||||
it("should resolve github-copilot Claude Opus to anthropic config", () => {
|
|
||||||
// given a github-copilot provider with Claude Opus model
|
|
||||||
const providerID = "github-copilot"
|
|
||||||
const modelID = "claude-opus-4-6"
|
|
||||||
|
|
||||||
// when getting thinking config
|
|
||||||
const config = getThinkingConfig(providerID, modelID)
|
|
||||||
|
|
||||||
// then should return anthropic thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
expect((config?.thinking as Record<string, unknown>)?.type).toBe(
|
|
||||||
"enabled"
|
|
||||||
)
|
|
||||||
expect((config?.thinking as Record<string, unknown>)?.budgetTokens).toBe(
|
|
||||||
64000
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should resolve github-copilot Claude Sonnet to anthropic config", () => {
|
|
||||||
// given a github-copilot provider with Claude Sonnet model
|
|
||||||
const config = getThinkingConfig("github-copilot", "claude-sonnet-4-6")
|
|
||||||
|
|
||||||
// then should return anthropic thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle Claude with dots in version number", () => {
|
|
||||||
// given a model ID with dots (claude-opus-4.6)
|
|
||||||
const config = getThinkingConfig("github-copilot", "claude-opus-4.6")
|
|
||||||
|
|
||||||
// then should still return anthropic thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Gemini models via github-copilot", () => {
|
|
||||||
it("should resolve github-copilot Gemini Pro to google config", () => {
|
|
||||||
// given a github-copilot provider with Gemini Pro model
|
|
||||||
const config = getThinkingConfig("github-copilot", "gemini-3-pro")
|
|
||||||
|
|
||||||
// then should return google thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
const googleOptions = (
|
|
||||||
config?.providerOptions as Record<string, unknown>
|
|
||||||
)?.google as Record<string, unknown>
|
|
||||||
expect(googleOptions?.thinkingConfig).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should resolve github-copilot Gemini Flash to google config", () => {
|
|
||||||
// given a github-copilot provider with Gemini Flash model
|
|
||||||
const config = getThinkingConfig(
|
|
||||||
"github-copilot",
|
|
||||||
"gemini-3-flash"
|
|
||||||
)
|
|
||||||
|
|
||||||
// then should return google thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("GPT models via github-copilot", () => {
|
|
||||||
it("should resolve github-copilot GPT-5.2 to openai config", () => {
|
|
||||||
// given a github-copilot provider with GPT-5.2 model
|
|
||||||
const config = getThinkingConfig("github-copilot", "gpt-5.2")
|
|
||||||
|
|
||||||
// then should return openai thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should resolve github-copilot GPT-5 to openai config", () => {
|
|
||||||
// given a github-copilot provider with GPT-5 model
|
|
||||||
const config = getThinkingConfig("github-copilot", "gpt-5")
|
|
||||||
|
|
||||||
// then should return openai thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should resolve github-copilot o1 to openai config", () => {
|
|
||||||
// given a github-copilot provider with o1 model
|
|
||||||
const config = getThinkingConfig("github-copilot", "o1-preview")
|
|
||||||
|
|
||||||
// then should return openai thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should resolve github-copilot o3 to openai config", () => {
|
|
||||||
// given a github-copilot provider with o3 model
|
|
||||||
const config = getThinkingConfig("github-copilot", "o3-mini")
|
|
||||||
|
|
||||||
// then should return openai thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Unknown models via github-copilot", () => {
|
|
||||||
it("should return null for unknown model types", () => {
|
|
||||||
// given a github-copilot provider with unknown model
|
|
||||||
const config = getThinkingConfig("github-copilot", "llama-3-70b")
|
|
||||||
|
|
||||||
// then should return null (no matching provider)
|
|
||||||
expect(config).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Model ID normalization", () => {
|
describe("Model ID normalization", () => {
|
||||||
describe("getHighVariant with dots vs hyphens", () => {
|
describe("getHighVariant with dots vs hyphens", () => {
|
||||||
it("should handle dots in Claude version numbers", () => {
|
it("should handle dots in Claude version numbers", () => {
|
||||||
@@ -217,149 +99,6 @@ describe("think-mode switcher", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getThinkingConfig", () => {
|
|
||||||
describe("Already high variants", () => {
|
|
||||||
it("should return null for already-high variants", () => {
|
|
||||||
// given already-high model variants
|
|
||||||
expect(
|
|
||||||
getThinkingConfig("anthropic", "claude-opus-4-6-high")
|
|
||||||
).toBeNull()
|
|
||||||
expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull()
|
|
||||||
expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return null for already-high variants via github-copilot", () => {
|
|
||||||
// given already-high model variants via github-copilot
|
|
||||||
expect(
|
|
||||||
getThinkingConfig("github-copilot", "claude-opus-4-6-high")
|
|
||||||
).toBeNull()
|
|
||||||
expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Non-thinking-capable models", () => {
|
|
||||||
it("should return null for non-thinking-capable models", () => {
|
|
||||||
// given models that don't support thinking mode
|
|
||||||
expect(getThinkingConfig("anthropic", "claude-2")).toBeNull()
|
|
||||||
expect(getThinkingConfig("openai", "gpt-4")).toBeNull()
|
|
||||||
expect(getThinkingConfig("google", "gemini-1")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Unknown providers", () => {
|
|
||||||
it("should return null for unknown providers", () => {
|
|
||||||
// given unknown provider IDs
|
|
||||||
expect(getThinkingConfig("unknown-provider", "some-model")).toBeNull()
|
|
||||||
expect(getThinkingConfig("azure", "gpt-5")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Direct provider configs (backwards compatibility)", () => {
|
|
||||||
it("should still work for direct anthropic provider", () => {
|
|
||||||
// given direct anthropic provider
|
|
||||||
const config = getThinkingConfig("anthropic", "claude-opus-4-6")
|
|
||||||
|
|
||||||
// then should return anthropic thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should work for direct google-vertex-anthropic provider", () => {
|
|
||||||
//#given direct google-vertex-anthropic provider
|
|
||||||
const config = getThinkingConfig(
|
|
||||||
"google-vertex-anthropic",
|
|
||||||
"claude-opus-4-6"
|
|
||||||
)
|
|
||||||
|
|
||||||
//#when thinking config is resolved
|
|
||||||
|
|
||||||
//#then it should return anthropic-style thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
|
|
||||||
expect((config?.thinking as Record<string, unknown>)?.budgetTokens).toBe(
|
|
||||||
64000
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for direct google provider", () => {
|
|
||||||
// given direct google provider
|
|
||||||
const config = getThinkingConfig("google", "gemini-3-pro")
|
|
||||||
|
|
||||||
// then should return google thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for amazon-bedrock provider", () => {
|
|
||||||
// given amazon-bedrock provider with claude model
|
|
||||||
const config = getThinkingConfig("amazon-bedrock", "claude-sonnet-4-6")
|
|
||||||
|
|
||||||
// then should return bedrock thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoningConfig).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should still work for google-vertex provider", () => {
|
|
||||||
// given google-vertex provider
|
|
||||||
const config = getThinkingConfig("google-vertex", "gemini-3-pro")
|
|
||||||
|
|
||||||
// then should return google-vertex thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
const vertexOptions = (config?.providerOptions as Record<string, unknown>)?.[
|
|
||||||
"google-vertex"
|
|
||||||
] as Record<string, unknown>
|
|
||||||
expect(vertexOptions?.thinkingConfig).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should work for direct openai provider", () => {
|
|
||||||
// given direct openai provider
|
|
||||||
const config = getThinkingConfig("openai", "gpt-5")
|
|
||||||
|
|
||||||
// then should return openai thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("THINKING_CONFIGS structure", () => {
|
|
||||||
it("should have correct structure for anthropic", () => {
|
|
||||||
const config = THINKING_CONFIGS.anthropic
|
|
||||||
expect(config.thinking).toBeDefined()
|
|
||||||
expect(config.maxTokens).toBe(128000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should have correct structure for google-vertex-anthropic", () => {
|
|
||||||
//#given google-vertex-anthropic config entry
|
|
||||||
const config = THINKING_CONFIGS["google-vertex-anthropic"]
|
|
||||||
|
|
||||||
//#when structure is validated
|
|
||||||
|
|
||||||
//#then it should match anthropic style structure
|
|
||||||
expect(config.thinking).toBeDefined()
|
|
||||||
expect(config.maxTokens).toBe(128000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should have correct structure for google", () => {
|
|
||||||
const config = THINKING_CONFIGS.google
|
|
||||||
expect(config.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should have correct structure for openai", () => {
|
|
||||||
const config = THINKING_CONFIGS.openai
|
|
||||||
expect(config.reasoning_effort).toBe("high")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should have correct structure for amazon-bedrock", () => {
|
|
||||||
const config = THINKING_CONFIGS["amazon-bedrock"]
|
|
||||||
expect(config.reasoningConfig).toBeDefined()
|
|
||||||
expect(config.maxTokens).toBe(64000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Custom provider prefixes support", () => {
|
describe("Custom provider prefixes support", () => {
|
||||||
describe("getHighVariant with prefixes", () => {
|
describe("getHighVariant with prefixes", () => {
|
||||||
it("should preserve vertex_ai/ prefix when getting high variant", () => {
|
it("should preserve vertex_ai/ prefix when getting high variant", () => {
|
||||||
@@ -426,141 +165,6 @@ describe("think-mode switcher", () => {
|
|||||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
|
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getThinkingConfig with prefixes", () => {
|
|
||||||
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
|
|
||||||
// given custom provider with prefixed Claude model
|
|
||||||
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-6")
|
|
||||||
|
|
||||||
// then should return null (custom provider not in THINKING_CONFIGS)
|
|
||||||
expect(config).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should work with prefixed models on known providers", () => {
|
|
||||||
// given known provider (anthropic) with prefixed model
|
|
||||||
// This tests that the base model name is correctly extracted for capability check
|
|
||||||
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-6")
|
|
||||||
|
|
||||||
// then should return thinking config (base model is capable)
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.thinking).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return null for prefixed models that are already high", () => {
|
|
||||||
// given prefixed already-high model
|
|
||||||
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-6-high")
|
|
||||||
|
|
||||||
// then should return null
|
|
||||||
expect(config).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Real-world custom provider scenario", () => {
|
|
||||||
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
|
|
||||||
// given a custom LLM proxy provider using vertex_ai/ prefix
|
|
||||||
const providerID = "dia-llm"
|
|
||||||
const modelID = "vertex_ai/claude-sonnet-4-6"
|
|
||||||
|
|
||||||
// when getting high variant
|
|
||||||
const highVariant = getHighVariant(modelID)
|
|
||||||
|
|
||||||
// then should preserve the prefix
|
|
||||||
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-6-high")
|
|
||||||
|
|
||||||
// #and when checking if already high
|
|
||||||
expect(isAlreadyHighVariant(modelID)).toBe(false)
|
|
||||||
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
|
|
||||||
|
|
||||||
// #and when getting thinking config for custom provider
|
|
||||||
const config = getThinkingConfig(providerID, modelID)
|
|
||||||
|
|
||||||
// then should return null (custom provider, not anthropic)
|
|
||||||
// This prevents applying incompatible thinking configs to custom providers
|
|
||||||
expect(config).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should not break when switching to high variant in think mode", () => {
|
|
||||||
// given think mode switching vertex_ai/claude model to high variant
|
|
||||||
const original = "vertex_ai/claude-opus-4-6"
|
|
||||||
const high = getHighVariant(original)
|
|
||||||
|
|
||||||
// then the high variant should be valid
|
|
||||||
expect(high).toBe("vertex_ai/claude-opus-4-6-high")
|
|
||||||
|
|
||||||
// #and should be recognized as already high
|
|
||||||
expect(isAlreadyHighVariant(high!)).toBe(true)
|
|
||||||
|
|
||||||
// #and switching again should return null (already high)
|
|
||||||
expect(getHighVariant(high!)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Z.AI GLM-4.7 provider support", () => {
|
|
||||||
describe("getThinkingConfig for zai-coding-plan", () => {
|
|
||||||
it("should return thinking config for glm-5", () => {
|
|
||||||
//#given a Z.ai GLM model
|
|
||||||
const config = getThinkingConfig("zai-coding-plan", "glm-5")
|
|
||||||
|
|
||||||
//#when thinking config is resolved
|
|
||||||
|
|
||||||
//#then thinking type is "disabled"
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
const zaiOptions = (config?.providerOptions as Record<string, unknown>)?.[
|
|
||||||
"zai-coding-plan"
|
|
||||||
] as Record<string, unknown>
|
|
||||||
expect(zaiOptions?.extra_body).toBeDefined()
|
|
||||||
const extraBody = zaiOptions?.extra_body as Record<string, unknown>
|
|
||||||
expect(extraBody?.thinking).toBeDefined()
|
|
||||||
expect((extraBody?.thinking as Record<string, unknown>)?.type).toBe("disabled")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return thinking config for glm-4.6v (multimodal)", () => {
|
|
||||||
// given zai-coding-plan provider with glm-4.6v model
|
|
||||||
const config = getThinkingConfig("zai-coding-plan", "glm-4.6v")
|
|
||||||
|
|
||||||
// then should return zai-coding-plan thinking config
|
|
||||||
expect(config).not.toBeNull()
|
|
||||||
expect(config?.providerOptions).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return null for non-GLM models on zai-coding-plan", () => {
|
|
||||||
// given zai-coding-plan provider with unknown model
|
|
||||||
const config = getThinkingConfig("zai-coding-plan", "some-other-model")
|
|
||||||
|
|
||||||
// then should return null
|
|
||||||
expect(config).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("HIGH_VARIANT_MAP for GLM", () => {
|
|
||||||
it("should NOT have high variant for glm-5", () => {
|
|
||||||
// given glm-5 model
|
|
||||||
const variant = getHighVariant("glm-5")
|
|
||||||
|
|
||||||
// then should return null (no high variant needed)
|
|
||||||
expect(variant).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should NOT have high variant for glm-4.6v", () => {
|
|
||||||
// given glm-4.6v model
|
|
||||||
const variant = getHighVariant("glm-4.6v")
|
|
||||||
|
|
||||||
// then should return null
|
|
||||||
expect(variant).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("THINKING_CONFIGS structure for zai-coding-plan", () => {
|
|
||||||
it("should have correct structure for zai-coding-plan", () => {
|
|
||||||
const config = THINKING_CONFIGS["zai-coding-plan"]
|
|
||||||
expect(config.providerOptions).toBeDefined()
|
|
||||||
const zaiOptions = (config.providerOptions as Record<string, unknown>)?.[
|
|
||||||
"zai-coding-plan"
|
|
||||||
] as Record<string, unknown>
|
|
||||||
expect(zaiOptions?.extra_body).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -53,35 +53,7 @@ function normalizeModelID(modelID: string): string {
|
|||||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves proxy providers (like github-copilot) to their underlying provider.
|
|
||||||
* This allows GitHub Copilot to inherit thinking configurations from the actual
|
|
||||||
* model provider (Anthropic, Google, OpenAI).
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* resolveProvider("github-copilot", "claude-opus-4-6") // "anthropic"
|
|
||||||
* resolveProvider("github-copilot", "gemini-3-pro") // "google"
|
|
||||||
* resolveProvider("github-copilot", "gpt-5.2") // "openai"
|
|
||||||
* resolveProvider("anthropic", "claude-opus-4-6") // "anthropic" (unchanged)
|
|
||||||
*/
|
|
||||||
function resolveProvider(providerID: string, modelID: string): string {
|
|
||||||
// GitHub Copilot is a proxy - infer actual provider from model name
|
|
||||||
if (providerID === "github-copilot") {
|
|
||||||
const modelLower = modelID.toLowerCase()
|
|
||||||
if (modelLower.includes("claude")) return "anthropic"
|
|
||||||
if (modelLower.includes("gemini")) return "google"
|
|
||||||
if (
|
|
||||||
modelLower.includes("gpt") ||
|
|
||||||
modelLower.includes("o1") ||
|
|
||||||
modelLower.includes("o3")
|
|
||||||
) {
|
|
||||||
return "openai"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct providers or unknown - return as-is
|
|
||||||
return providerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps model IDs to their "high reasoning" variant (internal convention)
|
// Maps model IDs to their "high reasoning" variant (internal convention)
|
||||||
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
||||||
@@ -116,71 +88,6 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
|
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
|
||||||
|
|
||||||
export const THINKING_CONFIGS = {
|
|
||||||
anthropic: {
|
|
||||||
thinking: {
|
|
||||||
type: "enabled",
|
|
||||||
budgetTokens: 64000,
|
|
||||||
},
|
|
||||||
maxTokens: 128000,
|
|
||||||
},
|
|
||||||
"google-vertex-anthropic": {
|
|
||||||
thinking: {
|
|
||||||
type: "enabled",
|
|
||||||
budgetTokens: 64000,
|
|
||||||
},
|
|
||||||
maxTokens: 128000,
|
|
||||||
},
|
|
||||||
"amazon-bedrock": {
|
|
||||||
reasoningConfig: {
|
|
||||||
type: "enabled",
|
|
||||||
budgetTokens: 32000,
|
|
||||||
},
|
|
||||||
maxTokens: 64000,
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
providerOptions: {
|
|
||||||
google: {
|
|
||||||
thinkingConfig: {
|
|
||||||
thinkingLevel: "HIGH",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"google-vertex": {
|
|
||||||
providerOptions: {
|
|
||||||
"google-vertex": {
|
|
||||||
thinkingConfig: {
|
|
||||||
thinkingLevel: "HIGH",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
reasoning_effort: "high",
|
|
||||||
},
|
|
||||||
"zai-coding-plan": {
|
|
||||||
providerOptions: {
|
|
||||||
"zai-coding-plan": {
|
|
||||||
extra_body: {
|
|
||||||
thinking: {
|
|
||||||
type: "disabled",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const satisfies Record<string, Record<string, unknown>>
|
|
||||||
|
|
||||||
const THINKING_CAPABLE_MODELS = {
|
|
||||||
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
|
|
||||||
"google-vertex-anthropic": ["claude-sonnet-4", "claude-opus-4", "claude-3"],
|
|
||||||
"amazon-bedrock": ["claude", "anthropic"],
|
|
||||||
google: ["gemini-2", "gemini-3"],
|
|
||||||
"google-vertex": ["gemini-2", "gemini-3"],
|
|
||||||
openai: ["gpt-5", "o1", "o3"],
|
|
||||||
"zai-coding-plan": ["glm"],
|
|
||||||
} as const satisfies Record<string, readonly string[]>
|
|
||||||
|
|
||||||
export function getHighVariant(modelID: string): string | null {
|
export function getHighVariant(modelID: string): string | null {
|
||||||
const normalized = normalizeModelID(modelID)
|
const normalized = normalizeModelID(modelID)
|
||||||
@@ -207,37 +114,4 @@ export function isAlreadyHighVariant(modelID: string): boolean {
|
|||||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThinkingProvider = keyof typeof THINKING_CONFIGS
|
|
||||||
|
|
||||||
function isThinkingProvider(provider: string): provider is ThinkingProvider {
|
|
||||||
return provider in THINKING_CONFIGS
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getThinkingConfig(
|
|
||||||
providerID: string,
|
|
||||||
modelID: string
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
const normalized = normalizeModelID(modelID)
|
|
||||||
const { base } = extractModelPrefix(normalized)
|
|
||||||
|
|
||||||
if (isAlreadyHighVariant(normalized)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedProvider = resolveProvider(providerID, modelID)
|
|
||||||
|
|
||||||
if (!isThinkingProvider(resolvedProvider)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = THINKING_CONFIGS[resolvedProvider]
|
|
||||||
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
|
|
||||||
|
|
||||||
// Check capability using base model name (without prefix)
|
|
||||||
const baseLower = base.toLowerCase()
|
|
||||||
const isCapable = capablePatterns.some((pattern) =>
|
|
||||||
baseLower.includes(pattern.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
return isCapable ? config : null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
export interface ThinkModeState {
|
export interface ThinkModeState {
|
||||||
requested: boolean
|
requested: boolean
|
||||||
modelSwitched: boolean
|
modelSwitched: boolean
|
||||||
thinkingConfigInjected: boolean
|
variantSet: boolean
|
||||||
providerID?: string
|
providerID?: string
|
||||||
modelID?: string
|
modelID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelRef {
|
interface ModelRef {
|
||||||
providerID: string
|
providerID: string
|
||||||
modelID: string
|
modelID: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithModel {
|
interface MessageWithModel {
|
||||||
model?: ModelRef
|
model?: ModelRef
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThinkModeInput {
|
|
||||||
parts: Array<{ type: string; text?: string }>
|
|
||||||
message: MessageWithModel
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
|
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export const TOAST_DURATION_MS = 900
|
|||||||
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||||
|
|
||||||
export const ABORT_WINDOW_MS = 3000
|
export const ABORT_WINDOW_MS = 3000
|
||||||
export const CONTINUATION_COOLDOWN_MS = 30_000
|
export const CONTINUATION_COOLDOWN_MS = 5_000
|
||||||
export const MAX_CONSECUTIVE_FAILURES = 5
|
export const MAX_CONSECUTIVE_FAILURES = 5
|
||||||
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/mcp/ — 3 Built-in Remote MCPs
|
# src/mcp/ — 3 Built-in Remote MCPs
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# src/plugin-handlers/ — 6-Phase Config Loading Pipeline
|
# src/plugin-handlers/ — 6-Phase Config Loading Pipeline
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test"
|
|||||||
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"
|
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"
|
||||||
|
|
||||||
describe("remapAgentKeysToDisplayNames", () => {
|
describe("remapAgentKeysToDisplayNames", () => {
|
||||||
it("remaps known agent keys to display names", () => {
|
it("remaps known agent keys to display names while preserving original keys", () => {
|
||||||
// given agents with lowercase keys
|
// given agents with lowercase keys
|
||||||
const agents = {
|
const agents = {
|
||||||
sisyphus: { prompt: "test", mode: "primary" },
|
sisyphus: { prompt: "test", mode: "primary" },
|
||||||
@@ -12,10 +12,11 @@ describe("remapAgentKeysToDisplayNames", () => {
|
|||||||
// when remapping
|
// when remapping
|
||||||
const result = remapAgentKeysToDisplayNames(agents)
|
const result = remapAgentKeysToDisplayNames(agents)
|
||||||
|
|
||||||
// then known agents get display name keys
|
// then known agents get display name keys and original keys remain accessible
|
||||||
expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
|
expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
|
||||||
expect(result["oracle"]).toBeDefined()
|
expect(result["oracle"]).toBeDefined()
|
||||||
expect(result["sisyphus"]).toBeUndefined()
|
expect(result["sisyphus"]).toBeDefined()
|
||||||
|
expect(result["Sisyphus (Ultraworker)"]).toBe(result["sisyphus"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("preserves unknown agent keys unchanged", () => {
|
it("preserves unknown agent keys unchanged", () => {
|
||||||
@@ -31,7 +32,7 @@ describe("remapAgentKeysToDisplayNames", () => {
|
|||||||
expect(result["custom-agent"]).toBeDefined()
|
expect(result["custom-agent"]).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("remaps all core agents", () => {
|
it("remaps all core agents while preserving original keys", () => {
|
||||||
// given all core agents
|
// given all core agents
|
||||||
const agents = {
|
const agents = {
|
||||||
sisyphus: {},
|
sisyphus: {},
|
||||||
@@ -46,15 +47,20 @@ describe("remapAgentKeysToDisplayNames", () => {
|
|||||||
// when remapping
|
// when remapping
|
||||||
const result = remapAgentKeysToDisplayNames(agents)
|
const result = remapAgentKeysToDisplayNames(agents)
|
||||||
|
|
||||||
// then all get display name keys
|
// then all get display name keys while original keys still work
|
||||||
expect(Object.keys(result)).toEqual([
|
expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
|
||||||
"Sisyphus (Ultraworker)",
|
expect(result["sisyphus"]).toBeDefined()
|
||||||
"Hephaestus (Deep Agent)",
|
expect(result["Hephaestus (Deep Agent)"]).toBeDefined()
|
||||||
"Prometheus (Plan Builder)",
|
expect(result["hephaestus"]).toBeDefined()
|
||||||
"Atlas (Plan Executor)",
|
expect(result["Prometheus (Plan Builder)"]).toBeDefined()
|
||||||
"Metis (Plan Consultant)",
|
expect(result["prometheus"]).toBeDefined()
|
||||||
"Momus (Plan Critic)",
|
expect(result["Atlas (Plan Executor)"]).toBeDefined()
|
||||||
"Sisyphus-Junior",
|
expect(result["atlas"]).toBeDefined()
|
||||||
])
|
expect(result["Metis (Plan Consultant)"]).toBeDefined()
|
||||||
|
expect(result["metis"]).toBeDefined()
|
||||||
|
expect(result["Momus (Plan Critic)"]).toBeDefined()
|
||||||
|
expect(result["momus"]).toBeDefined()
|
||||||
|
expect(result["Sisyphus-Junior"]).toBeDefined()
|
||||||
|
expect(result["sisyphus-junior"]).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function remapAgentKeysToDisplayNames(
|
|||||||
const displayName = AGENT_DISPLAY_NAMES[key]
|
const displayName = AGENT_DISPLAY_NAMES[key]
|
||||||
if (displayName && displayName !== key) {
|
if (displayName && displayName !== key) {
|
||||||
result[displayName] = value
|
result[displayName] = value
|
||||||
|
result[key] = value
|
||||||
} else {
|
} else {
|
||||||
result[key] = value
|
result[key] = value
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/plugin-handlers/config-handler-formatter.test.ts
Normal file
120
src/plugin-handlers/config-handler-formatter.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
|
import { createConfigHandler } from "./config-handler"
|
||||||
|
import * as agentConfigHandler from "./agent-config-handler"
|
||||||
|
import * as commandConfigHandler from "./command-config-handler"
|
||||||
|
import * as mcpConfigHandler from "./mcp-config-handler"
|
||||||
|
import * as pluginComponentsLoader from "./plugin-components-loader"
|
||||||
|
import * as providerConfigHandler from "./provider-config-handler"
|
||||||
|
import * as shared from "../shared"
|
||||||
|
import * as toolConfigHandler from "./tool-config-handler"
|
||||||
|
|
||||||
|
let logSpy: ReturnType<typeof spyOn>
|
||||||
|
let loadPluginComponentsSpy: ReturnType<typeof spyOn>
|
||||||
|
let applyAgentConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
let applyToolConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
let applyMcpConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
let applyCommandConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
let applyProviderConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logSpy = spyOn(shared, "log").mockImplementation(() => {})
|
||||||
|
loadPluginComponentsSpy = spyOn(
|
||||||
|
pluginComponentsLoader,
|
||||||
|
"loadPluginComponents",
|
||||||
|
).mockResolvedValue({
|
||||||
|
commands: {},
|
||||||
|
skills: {},
|
||||||
|
agents: {},
|
||||||
|
mcpServers: {},
|
||||||
|
hooksConfigs: [],
|
||||||
|
plugins: [],
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
applyAgentConfigSpy = spyOn(agentConfigHandler, "applyAgentConfig").mockResolvedValue(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
applyToolConfigSpy = spyOn(toolConfigHandler, "applyToolConfig").mockImplementation(
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
applyMcpConfigSpy = spyOn(mcpConfigHandler, "applyMcpConfig").mockResolvedValue()
|
||||||
|
applyCommandConfigSpy = spyOn(
|
||||||
|
commandConfigHandler,
|
||||||
|
"applyCommandConfig",
|
||||||
|
).mockResolvedValue()
|
||||||
|
applyProviderConfigSpy = spyOn(
|
||||||
|
providerConfigHandler,
|
||||||
|
"applyProviderConfig",
|
||||||
|
).mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy.mockRestore()
|
||||||
|
loadPluginComponentsSpy.mockRestore()
|
||||||
|
applyAgentConfigSpy.mockRestore()
|
||||||
|
applyToolConfigSpy.mockRestore()
|
||||||
|
applyMcpConfigSpy.mockRestore()
|
||||||
|
applyCommandConfigSpy.mockRestore()
|
||||||
|
applyProviderConfigSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createConfigHandler formatter pass-through", () => {
|
||||||
|
test("preserves formatter object configured in opencode config", async () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const formatterConfig = {
|
||||||
|
prettier: {
|
||||||
|
command: ["prettier", "--write"],
|
||||||
|
extensions: [".ts", ".tsx"],
|
||||||
|
environment: {
|
||||||
|
PRETTIERD_DEFAULT_CONFIG: ".prettierrc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
disabled: false,
|
||||||
|
command: ["eslint", "--fix"],
|
||||||
|
extensions: [".js", ".ts"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
formatter: formatterConfig,
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.formatter).toEqual(formatterConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves formatter=false configured in opencode config", async () => {
|
||||||
|
// given
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
formatter: false,
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.formatter).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
const { ctx, pluginConfig, modelCacheState } = deps;
|
const { ctx, pluginConfig, modelCacheState } = deps;
|
||||||
|
|
||||||
return async (config: Record<string, unknown>) => {
|
return async (config: Record<string, unknown>) => {
|
||||||
|
const formatterConfig = config.formatter;
|
||||||
|
|
||||||
applyProviderConfig({ config, modelCacheState });
|
applyProviderConfig({ config, modelCacheState });
|
||||||
|
|
||||||
const pluginComponents = await loadPluginComponents({ pluginConfig });
|
const pluginComponents = await loadPluginComponents({ pluginConfig });
|
||||||
@@ -35,6 +37,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
await applyMcpConfig({ config, pluginConfig, pluginComponents });
|
await applyMcpConfig({ config, pluginConfig, pluginComponents });
|
||||||
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||||
|
|
||||||
|
config.formatter = formatterConfig;
|
||||||
|
|
||||||
log("[config-handler] config handler applied", {
|
log("[config-handler] config handler applied", {
|
||||||
agentCount: Object.keys(agentResult).length,
|
agentCount: Object.keys(agentResult).length,
|
||||||
commandCount: Object.keys((config.command as Record<string, unknown>) ?? {})
|
commandCount: Object.keys((config.command as Record<string, unknown>) ?? {})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition
|
# src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition
|
||||||
|
|
||||||
**Generated:** 2026-02-21
|
**Generated:** 2026-02-24
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 44 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
||||||
|
|
||||||
## HANDLER FILES
|
## HANDLER FILES
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and com
|
|||||||
|------|------|-------|
|
|------|------|-------|
|
||||||
| `create-session-hooks.ts` | Session | 21 |
|
| `create-session-hooks.ts` | Session | 21 |
|
||||||
| `create-tool-guard-hooks.ts` | Tool Guard | 10 |
|
| `create-tool-guard-hooks.ts` | Tool Guard | 10 |
|
||||||
| `create-transform-hooks.ts` | Transform | 4 |
|
| `create-session-hooks.ts` | Session | 23 |
|
||||||
| `create-continuation-hooks.ts` | Continuation | 7 |
|
| `create-tool-guard-hooks.ts` | Tool Guard | 10 |
|
||||||
| `create-skill-hooks.ts` | Skill | 2 |
|
| `create-skill-hooks.ts` | Skill | 2 |
|
||||||
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 35 |
|
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 37 |
|
||||||
|
|
||||||
## SUPPORT FILES
|
## SUPPORT FILES
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createMockHandlerArgs(overrides?: {
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
stopContinuationGuard: null,
|
stopContinuationGuard: null,
|
||||||
|
backgroundNotificationHook: null,
|
||||||
keywordDetector: null,
|
keywordDetector: null,
|
||||||
claudeCodeHooks: null,
|
claudeCodeHooks: null,
|
||||||
autoSlashCommand: null,
|
autoSlashCommand: null,
|
||||||
@@ -115,4 +116,30 @@ describe("createChatMessageHandler - TUI variant passthrough", () => {
|
|||||||
//#then - gate should still be marked as applied
|
//#then - gate should still be marked as applied
|
||||||
expect(args._appliedSessions).toContain("test-session")
|
expect(args._appliedSessions).toContain("test-session")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("injects queued background notifications through chat.message hook", async () => {
|
||||||
|
//#given
|
||||||
|
const args = createMockHandlerArgs()
|
||||||
|
args.hooks.backgroundNotificationHook = {
|
||||||
|
"chat.message": async (
|
||||||
|
_input: { sessionID: string },
|
||||||
|
output: ChatMessageHandlerOutput,
|
||||||
|
): Promise<void> => {
|
||||||
|
output.parts.push({
|
||||||
|
type: "text",
|
||||||
|
text: "<system-reminder>[BACKGROUND TASK COMPLETED]</system-reminder>",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const handler = createChatMessageHandler(args)
|
||||||
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||||
|
const output = createMockOutput()
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.parts).toHaveLength(1)
|
||||||
|
expect(output.parts[0].text).toContain("[BACKGROUND TASK COMPLETED]")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ export function createChatMessageHandler(args: {
|
|||||||
setSessionModel(input.sessionID, input.model)
|
setSessionModel(input.sessionID, input.model)
|
||||||
}
|
}
|
||||||
await hooks.stopContinuationGuard?.["chat.message"]?.(input)
|
await hooks.stopContinuationGuard?.["chat.message"]?.(input)
|
||||||
|
await hooks.backgroundNotificationHook?.["chat.message"]?.(input, output)
|
||||||
await hooks.runtimeFallback?.["chat.message"]?.(input, output)
|
await hooks.runtimeFallback?.["chat.message"]?.(input, output)
|
||||||
await hooks.keywordDetector?.["chat.message"]?.(input, output)
|
await hooks.keywordDetector?.["chat.message"]?.(input, output)
|
||||||
|
await hooks.thinkMode?.["chat.message"]?.(input, output)
|
||||||
await hooks.claudeCodeHooks?.["chat.message"]?.(input, output)
|
await hooks.claudeCodeHooks?.["chat.message"]?.(input, output)
|
||||||
await hooks.autoSlashCommand?.["chat.message"]?.(input, output)
|
await hooks.autoSlashCommand?.["chat.message"]?.(input, output)
|
||||||
await hooks.noSisyphusGpt?.["chat.message"]?.(input, output)
|
await hooks.noSisyphusGpt?.["chat.message"]?.(input, output)
|
||||||
|
|||||||
@@ -232,7 +232,10 @@ export function createSessionHooks(args: {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt")
|
const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt")
|
||||||
? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx))
|
? safeHook("no-hephaestus-non-gpt", () =>
|
||||||
|
createNoHephaestusNonGptHook(ctx, {
|
||||||
|
allowNonGptModel: pluginConfig.agents?.hephaestus?.allow_non_gpt_model,
|
||||||
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const questionLabelTruncator = isHookEnabled("question-label-truncator")
|
const questionLabelTruncator = isHookEnabled("question-label-truncator")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user