Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5558ddf468 | ||
|
|
aa03d9b811 | ||
|
|
28a0dd06c7 | ||
|
|
995b7751af | ||
|
|
5087788f66 | ||
|
|
19524c8a27 | ||
|
|
fbb4d46945 | ||
|
|
5dc8d577a4 | ||
|
|
c249763d7e | ||
|
|
b2d618e851 | ||
|
|
6f348a8a5c | ||
|
|
1da0adcbe8 | ||
|
|
8a9d966a3d | ||
|
|
76f8c500cb | ||
|
|
388516bcc5 | ||
|
|
8dff875929 | ||
|
|
966cc90a02 | ||
|
|
1d27d78127 | ||
|
|
38156d49f3 | ||
|
|
897eea0263 | ||
|
|
9b59ef66e4 | ||
|
|
0d938059f9 | ||
|
|
9d35f23725 | ||
|
|
aa1646f82c | ||
|
|
e47ab084fd | ||
|
|
baf6358736 | ||
|
|
488c89156b | ||
|
|
c4957a469d | ||
|
|
d481c596bd | ||
|
|
655d511294 | ||
|
|
7dedd6cf90 | ||
|
|
bd18f231f5 | ||
|
|
de439edc22 | ||
|
|
04500bae7d | ||
|
|
1cb6b3de7d | ||
|
|
912a56db85 | ||
|
|
a5d9929c0a | ||
|
|
7f43f160b5 | ||
|
|
af67bc8592 | ||
|
|
c74d79e28a | ||
|
|
fc5298d778 | ||
|
|
3e8e3db961 | ||
|
|
6fa5cac616 | ||
|
|
158ccabf24 | ||
|
|
2efbf2650f | ||
|
|
acded4ba2a | ||
|
|
911e43445f | ||
|
|
3049e1ebfb |
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -44,8 +44,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
|
||||
branch: 'dev'
|
||||
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
|
||||
|
||||
|
||||
30
.github/workflows/publish.yml
vendored
30
.github/workflows/publish.yml
vendored
@@ -45,8 +45,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
38
.github/workflows/sisyphus-agent.yml
vendored
38
.github/workflows/sisyphus-agent.yml
vendored
@@ -152,6 +152,41 @@ jobs:
|
||||
"limit": { "context": 200000, "output": 64000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider["zai-coding-plan"] = {
|
||||
"name": "Z.AI Coding Plan",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://api.z.ai/api/paas/v4"
|
||||
},
|
||||
"models": {
|
||||
"glm-4.7": {
|
||||
"id": "glm-4.7",
|
||||
"name": "GLM 4.7",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"glm-4.6v": {
|
||||
"id": "glm-4.6v",
|
||||
"name": "GLM 4.6 Vision",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider.openai = {
|
||||
"name": "OpenAI",
|
||||
"npm": "@ai-sdk/openai",
|
||||
"models": {
|
||||
"gpt-5.2": {
|
||||
"id": "gpt-5.2",
|
||||
"name": "GPT-5.2",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "GPT-5.2 Codex",
|
||||
"limit": { "context": 128000, "output": 32000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
|
||||
|
||||
@@ -287,6 +322,9 @@ jobs:
|
||||
)
|
||||
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
# Add categories configuration for unspecified-low to use GLM 4.7
|
||||
jq '.categories["unspecified-low"] = { "model": "zai-coding-plan/glm-4.7" }' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
mkdir -p ~/.local/share/opencode
|
||||
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
|
||||
chmod 600 ~/.local/share/opencode/auth.json
|
||||
|
||||
@@ -2768,7 +2768,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playwright",
|
||||
"agent-browser"
|
||||
"agent-browser",
|
||||
"dev-browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2808,6 +2809,50 @@
|
||||
"minimum": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"sisyphus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/tasks",
|
||||
"type": "string"
|
||||
},
|
||||
"claude_code_compat": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"swarm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/teams",
|
||||
"type": "string"
|
||||
},
|
||||
"ui_mode": {
|
||||
"default": "toast",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"toast",
|
||||
"tmux",
|
||||
"both"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
bun.lock
28
bun.lock
@@ -27,13 +27,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.0",
|
||||
"oh-my-opencode-darwin-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.0",
|
||||
"oh-my-opencode-linux-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.0",
|
||||
"oh-my-opencode-windows-x64": "3.1.0",
|
||||
"oh-my-opencode-darwin-arm64": "3.1.2",
|
||||
"oh-my-opencode-darwin-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.2",
|
||||
"oh-my-opencode-linux-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.2",
|
||||
"oh-my-opencode-windows-x64": "3.1.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,20 +225,6 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8j7XI+n1bz7xIg35Zpjqp1AqoIoFWuVZdYyI9vTAZ0b6ta/mIlNOWPLAbFyEHfKelA9g3Xa+4sYnKPSxU5dQoA=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Kd/3KpnF07cw+qBAyLwA0y8tp3S0X8b8HWH55WGlVp6m4gvQ432kKgDum/jat1vqP/3J8hm4P/sly5ibY5gMqw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-qy/QohHGM6eSQjHVEgibsDauUvlAgYPw5xrQqa9cVLo1hL4KMIhb+i4wGAxCK2p84rG2bfC2m8+IfZUxhhwcTg=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-HIO7zj3M5QAYOfgvFM7Djeuen9kdZD4RA51wzXcXiPj1FPAuBNAW9N7lTEGYBSgObgwX+vXnC3HwLSF7nqkw8w=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zcKaibnEhvbReiTsqbg+dog/Z3pnBx4v6R3AR5nVhGBO27hRSAXgA/fviYyE5bWD591WB7Pqwduf0t854ilKjw=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xmtHEyAhY93Djg5qEauvMqSF0x3tf8pzOGdKB6CuZmhCG69fZXk/dEwPrO0vKbOeGMV/T4K6HAg1+8Ue1N1ZaQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pDgHd0mGWWVsiO0fT8C7bi6CziOXU38g+k2dWlGm1YXCMzyrrWZZCF7oIp+EzJB02saSCF/oJ2f1/uj/VPeLMA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
@@ -525,27 +525,96 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
|
||||
Categories enable domain-specific task delegation via the `delegate_task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent.
|
||||
|
||||
**Default Categories:**
|
||||
### Built-in Categories
|
||||
|
||||
| Category | Model | Description |
|
||||
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
|
||||
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
|
||||
All 7 categories come with optimal model defaults, but **you must configure them to use those defaults**:
|
||||
|
||||
**Usage:**
|
||||
| Category | Built-in Default Model | Description |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
|
||||
|
||||
### ⚠️ Critical: Model Resolution Priority
|
||||
|
||||
**Categories DO NOT use their built-in defaults unless configured.** Model resolution follows this priority:
|
||||
|
||||
```
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="business-logic", prompt="Design the payment processing flow")
|
||||
1. User-configured model (in oh-my-opencode.json)
|
||||
2. Category's built-in default (if you add category to config)
|
||||
3. System default model (from opencode.json)
|
||||
```
|
||||
|
||||
// Or target a specific agent directly
|
||||
**Example Problem:**
|
||||
|
||||
```json
|
||||
// opencode.json
|
||||
{ "model": "anthropic/claude-sonnet-4-5" }
|
||||
|
||||
// oh-my-opencode.json (empty categories section)
|
||||
{}
|
||||
|
||||
// Result: ALL categories use claude-sonnet-4-5 (wasteful!)
|
||||
// - quick tasks use Sonnet instead of Haiku (expensive)
|
||||
// - ultrabrain uses Sonnet instead of GPT-5.2 (inferior reasoning)
|
||||
// - visual tasks use Sonnet instead of Gemini (suboptimal for UI)
|
||||
```
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
**To use optimal models for each category, add them to your config:**
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview"
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "xhigh"
|
||||
},
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"variant": "max"
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5" // Fast + cheap for trivial tasks
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "anthropic/claude-sonnet-4-5"
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max"
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Only configure categories you have access to.** Unconfigured categories fall back to your system default model.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual-engineering", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="ultrabrain", prompt="Design the payment processing flow")
|
||||
|
||||
// Or target a specific agent directly (bypasses categories)
|
||||
delegate_task(agent="oracle", prompt="Review this architecture")
|
||||
```
|
||||
|
||||
**Custom Categories:**
|
||||
### Custom Categories
|
||||
|
||||
Add custom categories in `oh-my-opencode.json`:
|
||||
Add your own categories or override built-in ones:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -555,15 +624,15 @@ Add custom categories in `oh-my-opencode.json`:
|
||||
"temperature": 0.2,
|
||||
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
|
||||
},
|
||||
"visual": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`.
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`.
|
||||
|
||||
## Model Resolution System
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -73,13 +73,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.2",
|
||||
"oh-my-opencode-darwin-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.2",
|
||||
"oh-my-opencode-linux-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.2",
|
||||
"oh-my-opencode-windows-x64": "3.1.2"
|
||||
"oh-my-opencode-darwin-arm64": "3.1.4",
|
||||
"oh-my-opencode-darwin-x64": "3.1.4",
|
||||
"oh-my-opencode-linux-arm64": "3.1.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.4",
|
||||
"oh-my-opencode-linux-x64": "3.1.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.4",
|
||||
"oh-my-opencode-windows-x64": "3.1.4"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/darwin-x64-baseline/package.json
Normal file
22
packages/darwin-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
25
packages/linux-x64-baseline/package.json
Normal file
25
packages/linux-x64-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
25
packages/linux-x64-musl-baseline/package.json
Normal file
25
packages/linux-x64-musl-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/windows-x64-baseline/package.json
Normal file
22
packages/windows-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.exe"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
79
script/build-binaries.test.ts
Normal file
79
script/build-binaries.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// script/build-binaries.test.ts
|
||||
// Tests for platform binary build configuration
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
// Import PLATFORMS from build-binaries.ts
|
||||
// We need to export it first, but for now we'll test the expected structure
|
||||
const EXPECTED_BASELINE_TARGETS = [
|
||||
"bun-linux-x64-baseline",
|
||||
"bun-linux-x64-musl-baseline",
|
||||
"bun-darwin-x64-baseline",
|
||||
"bun-windows-x64-baseline",
|
||||
];
|
||||
|
||||
describe("build-binaries", () => {
|
||||
describe("PLATFORMS array", () => {
|
||||
it("includes baseline variants for non-AVX2 CPU support", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string }[] }).PLATFORMS;
|
||||
const targets = platforms.map((p) => p.target);
|
||||
|
||||
// when
|
||||
const hasAllBaselineTargets = EXPECTED_BASELINE_TARGETS.every((baseline) =>
|
||||
targets.includes(baseline)
|
||||
);
|
||||
|
||||
// then
|
||||
expect(hasAllBaselineTargets).toBe(true);
|
||||
for (const baseline of EXPECTED_BASELINE_TARGETS) {
|
||||
expect(targets).toContain(baseline);
|
||||
}
|
||||
});
|
||||
|
||||
it("has correct directory names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
expect(baselinePlatforms.length).toBe(4);
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-musl-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("darwin-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("windows-x64-baseline");
|
||||
});
|
||||
|
||||
it("has correct binary names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string; binary: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const windowsBaseline = platforms.find((p) => p.target === "bun-windows-x64-baseline");
|
||||
const linuxBaseline = platforms.find((p) => p.target === "bun-linux-x64-baseline");
|
||||
|
||||
// then
|
||||
expect(windowsBaseline?.binary).toBe("oh-my-opencode.exe");
|
||||
expect(linuxBaseline?.binary).toBe("oh-my-opencode");
|
||||
});
|
||||
|
||||
it("has descriptions mentioning no AVX2 for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string; description: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
for (const platform of baselinePlatforms) {
|
||||
expect(platform.description).toContain("no AVX2");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,18 @@ interface PlatformTarget {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: PlatformTarget[] = [
|
||||
export const PLATFORMS: PlatformTarget[] = [
|
||||
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
|
||||
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
|
||||
{ dir: "darwin-x64-baseline", target: "bun-darwin-x64-baseline", binary: "oh-my-opencode", description: "macOS x64 (no AVX2)" },
|
||||
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
|
||||
{ dir: "linux-x64-baseline", target: "bun-linux-x64-baseline", binary: "oh-my-opencode", description: "Linux x64 (glibc, no AVX2)" },
|
||||
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
|
||||
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
|
||||
{ dir: "linux-x64-musl-baseline", target: "bun-linux-x64-musl-baseline", binary: "oh-my-opencode", description: "Linux x64 (musl, no AVX2)" },
|
||||
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
|
||||
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
|
||||
{ dir: "windows-x64-baseline", target: "bun-windows-x64-baseline", binary: "oh-my-opencode.exe", description: "Windows x64 (no AVX2)" },
|
||||
];
|
||||
|
||||
const ENTRY_POINT = "src/cli/index.ts";
|
||||
|
||||
@@ -879,6 +879,54 @@
|
||||
"created_at": "2026-01-26T23:20:30Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1157
|
||||
},
|
||||
{
|
||||
"name": "ghtndl",
|
||||
"id": 117787238,
|
||||
"comment_id": 3802593326,
|
||||
"created_at": "2026-01-27T01:27:17Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1158
|
||||
},
|
||||
{
|
||||
"name": "alvinunreal",
|
||||
"id": 204474669,
|
||||
"comment_id": 3796402213,
|
||||
"created_at": "2026-01-25T10:26:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1100
|
||||
},
|
||||
{
|
||||
"name": "MoerAI",
|
||||
"id": 26067127,
|
||||
"comment_id": 3803968993,
|
||||
"created_at": "2026-01-27T09:00:57Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1172
|
||||
},
|
||||
{
|
||||
"name": "moha-abdi",
|
||||
"id": 83307623,
|
||||
"comment_id": 3804988070,
|
||||
"created_at": "2026-01-27T12:36:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1179
|
||||
},
|
||||
{
|
||||
"name": "zycaskevin",
|
||||
"id": 223135116,
|
||||
"comment_id": 3806137669,
|
||||
"created_at": "2026-01-27T16:20:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1184
|
||||
},
|
||||
{
|
||||
"name": "agno01",
|
||||
"id": 4479380,
|
||||
"comment_id": 3808373433,
|
||||
"created_at": "2026-01-28T01:02:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1188
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -863,6 +863,20 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
|
||||
\`\`\`markdown
|
||||
# {Plan Title}
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: [1-2 sentences capturing the core objective and approach]
|
||||
>
|
||||
> **Deliverables**: [Bullet list of concrete outputs]
|
||||
> - [Output 1]
|
||||
> - [Output 2]
|
||||
>
|
||||
> **Estimated Effort**: [Quick | Short | Medium | Large | XL]
|
||||
> **Parallel Execution**: [YES - N waves | NO - sequential]
|
||||
> **Critical Path**: [Task X → Task Y → Task Z]
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
@@ -963,29 +977,55 @@ Each TODO includes detailed verification procedures:
|
||||
|
||||
---
|
||||
|
||||
## Task Flow
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
> Maximize throughput by grouping independent tasks into parallel waves.
|
||||
> Each wave completes before the next begins.
|
||||
|
||||
\`\`\`
|
||||
Task 1 → Task 2 → Task 3
|
||||
↘ Task 4 (parallel)
|
||||
Wave 1 (Start Immediately):
|
||||
├── Task 1: [no dependencies]
|
||||
└── Task 5: [no dependencies]
|
||||
|
||||
Wave 2 (After Wave 1):
|
||||
├── Task 2: [depends: 1]
|
||||
├── Task 3: [depends: 1]
|
||||
└── Task 6: [depends: 5]
|
||||
|
||||
Wave 3 (After Wave 2):
|
||||
└── Task 4: [depends: 2, 3]
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 4
|
||||
Parallel Speedup: ~40% faster than sequential
|
||||
\`\`\`
|
||||
|
||||
## Parallelization
|
||||
### Dependency Matrix
|
||||
|
||||
| Group | Tasks | Reason |
|
||||
|-------|-------|--------|
|
||||
| A | 2, 3 | Independent files |
|
||||
| Task | Depends On | Blocks | Can Parallelize With |
|
||||
|------|------------|--------|---------------------|
|
||||
| 1 | None | 2, 3 | 5 |
|
||||
| 2 | 1 | 4 | 3, 6 |
|
||||
| 3 | 1 | 4 | 2, 6 |
|
||||
| 4 | 2, 3 | None | None (final) |
|
||||
| 5 | None | 6 | 1 |
|
||||
| 6 | 5 | None | 2, 3 |
|
||||
|
||||
| Task | Depends On | Reason |
|
||||
|------|------------|--------|
|
||||
| 4 | 1 | Requires output from 1 |
|
||||
### Agent Dispatch Summary
|
||||
|
||||
| Wave | Tasks | Recommended Agents |
|
||||
|------|-------|-------------------|
|
||||
| 1 | 1, 5 | delegate_task(category="...", load_skills=[...], run_in_background=true) |
|
||||
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
|
||||
| 3 | 4 | final integration task |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
> Implementation + Test = ONE Task. Never separate.
|
||||
> Specify parallelizability for EVERY task.
|
||||
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
|
||||
|
||||
- [ ] 1. [Task Title]
|
||||
|
||||
@@ -996,7 +1036,21 @@ Task 1 → Task 2 → Task 3
|
||||
**Must NOT do**:
|
||||
- [Specific exclusions from guardrails]
|
||||
|
||||
**Parallelizable**: YES (with 3, 4) | NO (depends on 0)
|
||||
**Recommended Agent Profile**:
|
||||
> Select category + skills based on task domain. Justify each choice.
|
||||
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
|
||||
- Reason: [Why this category fits the task domain]
|
||||
- **Skills**: [\`skill-1\`, \`skill-2\`]
|
||||
- \`skill-1\`: [Why needed - domain overlap explanation]
|
||||
- \`skill-2\`: [Why needed - domain overlap explanation]
|
||||
- **Skills Evaluated but Omitted**:
|
||||
- \`omitted-skill\`: [Why domain doesn't overlap]
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES | NO
|
||||
- **Parallel Group**: Wave N (with Tasks X, Y) | Sequential
|
||||
- **Blocks**: [Tasks that depend on this task completing]
|
||||
- **Blocked By**: [Tasks this depends on] | None (can start immediately)
|
||||
|
||||
**References** (CRITICAL - Be Exhaustive):
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test"
|
||||
import { createBuiltinAgents } from "./utils"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
@@ -46,17 +47,32 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
|
||||
// #given - no available models simulates CI without model cache
|
||||
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
|
||||
// #given - connected providers cache exists with openai
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
|
||||
// #then - uses openai from connected cache
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.textVerbosity).toBe("high")
|
||||
expect(agents.oracle.thinking).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||
// #given - no cache at all (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
||||
@@ -107,26 +123,42 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
test("creates agents successfully without systemDefaultModel", async () => {
|
||||
// #given - no systemDefaultModel provided
|
||||
test("creates agents with connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - agents should still be created using fallback chain
|
||||
// #then - agents should use connected provider from fallback chain
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => {
|
||||
// #given - no systemDefaultModel
|
||||
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||
// #given - no cache and no system default
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - sisyphus should use its fallback chain
|
||||
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("sisyphus uses connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists with anthropic
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - sisyphus should use anthropic from connected cache
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,8 +31,18 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Support custom OpenCode server port via environment variable
|
||||
// This allows Open Agent and other orchestrators to run multiple
|
||||
// concurrent missions without port conflicts
|
||||
const serverPort = process.env.OPENCODE_SERVER_PORT
|
||||
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
|
||||
: undefined
|
||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined
|
||||
|
||||
const { client, server } = await createOpencode({
|
||||
signal: abortController.signal,
|
||||
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}),
|
||||
...(serverHostname ? { hostname: serverHostname } : {}),
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -313,13 +313,14 @@ export const GitMasterConfigSchema = z.object({
|
||||
include_co_authored_by: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser"])
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"])
|
||||
|
||||
export const BrowserAutomationConfigSchema = z.object({
|
||||
/**
|
||||
* Browser automation provider to use for the "playwright" skill.
|
||||
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
|
||||
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
|
||||
* - "dev-browser": Uses dev-browser skill with persistent browser state
|
||||
*/
|
||||
provider: BrowserAutomationProviderSchema.default("playwright"),
|
||||
})
|
||||
@@ -339,6 +340,29 @@ export const TmuxConfigSchema = z.object({
|
||||
main_pane_min_width: z.number().min(40).default(120),
|
||||
agent_pane_min_width: z.number().min(20).default(40),
|
||||
})
|
||||
|
||||
export const SisyphusTasksConfigSchema = z.object({
|
||||
/** Enable Sisyphus Tasks system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for tasks (default: .sisyphus/tasks) */
|
||||
storage_path: z.string().default(".sisyphus/tasks"),
|
||||
/** Enable Claude Code path compatibility mode */
|
||||
claude_code_compat: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const SisyphusSwarmConfigSchema = z.object({
|
||||
/** Enable Sisyphus Swarm system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for teams (default: .sisyphus/teams) */
|
||||
storage_path: z.string().default(".sisyphus/teams"),
|
||||
/** UI mode: toast notifications, tmux panes, or both */
|
||||
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
|
||||
})
|
||||
|
||||
export const SisyphusConfigSchema = z.object({
|
||||
tasks: SisyphusTasksConfigSchema.optional(),
|
||||
swarm: SisyphusSwarmConfigSchema.optional(),
|
||||
})
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
@@ -360,6 +384,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
tmux: TmuxConfigSchema.optional(),
|
||||
sisyphus: SisyphusConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
@@ -386,5 +411,8 @@ export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProvider
|
||||
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
|
||||
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
@@ -224,7 +224,10 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -294,11 +297,19 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
@@ -542,11 +553,18 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop
|
||||
// Include model if task has one (preserved from original launch with category config)
|
||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
||||
const resumeModel = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(existingTask.model ? { model: existingTask.model } : {}),
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import {
|
||||
setSessionAgent,
|
||||
getSessionAgent,
|
||||
@@ -13,9 +13,11 @@ describe("claude-code-session-state", () => {
|
||||
beforeEach(() => {
|
||||
// #given - clean state before each test
|
||||
_resetForTesting()
|
||||
clearSessionAgent("test-session-1")
|
||||
clearSessionAgent("test-session-2")
|
||||
clearSessionAgent("test-prometheus-session")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// #then - cleanup after each test to prevent pollution
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("setSessionAgent", () => {
|
||||
@@ -92,9 +94,9 @@ describe("claude-code-session-state", () => {
|
||||
expect(getMainSessionID()).toBe(mainID)
|
||||
})
|
||||
|
||||
test.skip("should return undefined when not set", () => {
|
||||
// #given - not set
|
||||
// TODO: Fix flaky test - parallel test execution causes state pollution
|
||||
test("should return undefined when not set", () => {
|
||||
// #given - explicit reset to ensure clean state (parallel test isolation)
|
||||
_resetForTesting()
|
||||
// #then
|
||||
expect(getMainSessionID()).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
|
||||
export function _resetForTesting(): void {
|
||||
_mainSessionID = undefined
|
||||
subagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
MailboxMessageSchema,
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
TaskAssignmentSchema,
|
||||
JoinRequestSchema,
|
||||
ProtocolMessageSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
//#given a valid mailbox message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: '{"type":"idle_notification"}',
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a message with optional color
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: "{}",
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
color: "blue",
|
||||
read: true,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
//#given permission_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
requestId: "req-123",
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
agentId: "agent-001",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given permission_response message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "approved",
|
||||
updatedInput: { command: "ls" },
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
|
||||
|
||||
const rejected = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "rejected",
|
||||
feedback: "Too dangerous",
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given shutdown_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses shutdown messages", () => {
|
||||
const request = { type: "shutdown_request" }
|
||||
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given task_assignment message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
taskId: "1",
|
||||
subject: "Fix bug",
|
||||
description: "Fix the auth bug",
|
||||
assignedBy: "team-lead",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given join_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
agentName: "new-agent",
|
||||
sessionId: "sess-123",
|
||||
}
|
||||
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const MailboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
color: z.string().optional(),
|
||||
read: z.boolean(),
|
||||
})
|
||||
|
||||
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
|
||||
|
||||
export const PermissionRequestSchema = z.object({
|
||||
type: z.literal("permission_request"),
|
||||
requestId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
|
||||
|
||||
export const PermissionResponseSchema = z.object({
|
||||
type: z.literal("permission_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
updatedInput: z.unknown().optional(),
|
||||
feedback: z.string().optional(),
|
||||
permissionUpdates: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
|
||||
|
||||
export const ShutdownRequestSchema = z.object({
|
||||
type: z.literal("shutdown_request"),
|
||||
})
|
||||
|
||||
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
|
||||
|
||||
export const ShutdownApprovedSchema = z.object({
|
||||
type: z.literal("shutdown_approved"),
|
||||
})
|
||||
|
||||
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
|
||||
|
||||
export const ShutdownRejectedSchema = z.object({
|
||||
type: z.literal("shutdown_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
|
||||
|
||||
export const TaskAssignmentSchema = z.object({
|
||||
type: z.literal("task_assignment"),
|
||||
taskId: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
assignedBy: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
|
||||
|
||||
export const TaskCompletedSchema = z.object({
|
||||
type: z.literal("task_completed"),
|
||||
taskId: z.string(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
|
||||
|
||||
export const IdleNotificationSchema = z.object({
|
||||
type: z.literal("idle_notification"),
|
||||
})
|
||||
|
||||
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
|
||||
|
||||
export const JoinRequestSchema = z.object({
|
||||
type: z.literal("join_request"),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
export type JoinRequest = z.infer<typeof JoinRequestSchema>
|
||||
|
||||
export const JoinApprovedSchema = z.object({
|
||||
type: z.literal("join_approved"),
|
||||
agentName: z.string(),
|
||||
teamName: z.string(),
|
||||
})
|
||||
|
||||
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
|
||||
|
||||
export const JoinRejectedSchema = z.object({
|
||||
type: z.literal("join_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
|
||||
|
||||
export const PlanApprovalRequestSchema = z.object({
|
||||
type: z.literal("plan_approval_request"),
|
||||
requestId: z.string(),
|
||||
plan: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
|
||||
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
|
||||
|
||||
export const PlanApprovalResponseSchema = z.object({
|
||||
type: z.literal("plan_approval_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
|
||||
|
||||
export const ModeSetRequestSchema = z.object({
|
||||
type: z.literal("mode_set_request"),
|
||||
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
|
||||
})
|
||||
|
||||
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
|
||||
|
||||
export const TeamPermissionUpdateSchema = z.object({
|
||||
type: z.literal("team_permission_update"),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
|
||||
|
||||
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
ShutdownApprovedSchema,
|
||||
ShutdownRejectedSchema,
|
||||
TaskAssignmentSchema,
|
||||
TaskCompletedSchema,
|
||||
IdleNotificationSchema,
|
||||
JoinRequestSchema,
|
||||
JoinApprovedSchema,
|
||||
JoinRejectedSchema,
|
||||
PlanApprovalRequestSchema,
|
||||
PlanApprovalResponseSchema,
|
||||
ModeSetRequestSchema,
|
||||
TeamPermissionUpdateSchema,
|
||||
])
|
||||
|
||||
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>
|
||||
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getTaskDir,
|
||||
getTaskPath,
|
||||
getTeamDir,
|
||||
getInboxPath,
|
||||
ensureDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "./storage"
|
||||
|
||||
const TEST_DIR = join(import.meta.dirname, ".test-storage")
|
||||
|
||||
describe("Storage Utilities", () => {
|
||||
beforeEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
//#given default config (no claude_code_compat)
|
||||
//#when getting task directory
|
||||
//#then it should return .sisyphus/tasks/{listId}
|
||||
it("returns sisyphus path by default", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".sisyphus/tasks/list-123")
|
||||
})
|
||||
|
||||
//#given claude_code_compat enabled
|
||||
//#when getting task directory
|
||||
//#then it should return Claude Code path
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".sisyphus/tasks",
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".cache/claude-code/tasks/list-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
//#given list and task IDs
|
||||
//#when getting task path
|
||||
//#then it should return path to task JSON file
|
||||
it("returns path to task JSON", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskPath("list-123", "1", config as any)
|
||||
expect(result).toContain("list-123/1.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
//#given team name and default config
|
||||
//#when getting team directory
|
||||
//#then it should return .sisyphus/teams/{teamName}
|
||||
it("returns sisyphus team path", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getTeamDir("my-team", config as any)
|
||||
expect(result).toContain(".sisyphus/teams/my-team")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
//#given team and agent names
|
||||
//#when getting inbox path
|
||||
//#then it should return path to inbox JSON file
|
||||
it("returns path to inbox JSON", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getInboxPath("my-team", "agent-001", config as any)
|
||||
expect(result).toContain("my-team/inboxes/agent-001.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
//#given a non-existent directory path
|
||||
//#when calling ensureDir
|
||||
//#then it should create the directory
|
||||
it("creates directory if not exists", () => {
|
||||
const dirPath = join(TEST_DIR, "new-dir", "nested")
|
||||
ensureDir(dirPath)
|
||||
expect(existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
|
||||
//#given an existing directory
|
||||
//#when calling ensureDir
|
||||
//#then it should not throw
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
expect(() => ensureDir(dirPath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
//#given a valid JSON file matching schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return parsed object
|
||||
it("reads and parses valid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string(), value: z.number() })
|
||||
const filePath = join(TEST_DIR, "test.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
//#given a non-existent file
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for non-existent file", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given invalid JSON content
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
writeFileSync(filePath, "not valid json")
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given JSON that doesn't match schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for schema mismatch", () => {
|
||||
const testSchema = z.object({ name: z.string(), required: z.number() })
|
||||
const filePath = join(TEST_DIR, "mismatch.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test" }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
//#given data to write
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should write to file atomically
|
||||
it("writes JSON atomically", () => {
|
||||
const filePath = join(TEST_DIR, "atomic.json")
|
||||
const data = { key: "value", number: 123 }
|
||||
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
//#given a deeply nested path
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should create parent directories
|
||||
it("creates parent directories", () => {
|
||||
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
|
||||
writeJsonAtomic(filePath, { test: true })
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/features/sisyphus-tasks/storage.ts
Normal file
82
src/features/sisyphus-tasks/storage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
|
||||
if (tasksConfig?.claude_code_compat) {
|
||||
return join(homedir(), ".cache", "claude-code", "tasks", listId)
|
||||
}
|
||||
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath, listId)
|
||||
}
|
||||
|
||||
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTaskDir(listId, config), `${taskId}.json`)
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const swarmConfig = config.sisyphus?.swarm
|
||||
|
||||
if (swarmConfig?.storage_path?.includes("claude")) {
|
||||
return join(homedir(), ".claude", "teams", teamName)
|
||||
}
|
||||
|
||||
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
|
||||
return join(process.cwd(), storagePath, teamName)
|
||||
}
|
||||
|
||||
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
82
src/features/sisyphus-tasks/types.test.ts
Normal file
82
src/features/sisyphus-tasks/types.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
//#given a valid task object
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Fix authentication bug",
|
||||
description: "Users report 401 errors",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a task with all optional fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses task with optional fields", () => {
|
||||
const taskWithOptionals = {
|
||||
id: "2",
|
||||
subject: "Add unit tests",
|
||||
description: "Write tests for auth module",
|
||||
activeForm: "Adding unit tests",
|
||||
owner: "agent-001",
|
||||
status: "in_progress",
|
||||
blocks: ["3"],
|
||||
blockedBy: ["1"],
|
||||
metadata: { priority: "high", labels: ["bug"] },
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given an invalid status value
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
//#given missing required fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
// missing subject, description, status, blocks, blockedBy
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
//#given valid status values
|
||||
//#when parsing
|
||||
//#then all should succeed
|
||||
it("accepts valid statuses", () => {
|
||||
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
|
||||
})
|
||||
})
|
||||
41
src/features/sisyphus-tasks/types.ts
Normal file
41
src/features/sisyphus-tasks/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
subject: z.string().describe("Task title"),
|
||||
description: z.string().describe("Detailed description"),
|
||||
activeForm: z.string().optional().describe("Text shown when in progress"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
taskId: z.string().describe("Task ID to update"),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
|
||||
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
@@ -396,9 +396,9 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
})
|
||||
|
||||
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
|
||||
@@ -417,7 +417,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
|
||||
@@ -438,7 +438,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
|
||||
@@ -462,7 +462,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
|
||||
cleanupMessageStorage(nonOrchestratorSession)
|
||||
})
|
||||
@@ -526,7 +526,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
|
||||
@@ -547,7 +547,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
|
||||
@@ -568,7 +568,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should append reminder for Windows path outside .sisyphus\\", async () => {
|
||||
@@ -587,7 +587,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -636,7 +636,7 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("BOULDER CONTINUATION")
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
|
||||
@@ -170,6 +170,20 @@ export function getCachedVersion(): string | null {
|
||||
log("[auto-update-checker] Failed to resolve version from current directory:", err)
|
||||
}
|
||||
|
||||
// Fallback for compiled binaries (npm global install)
|
||||
// process.execPath points to the actual binary location
|
||||
try {
|
||||
const execDir = path.dirname(fs.realpathSync(process.execPath))
|
||||
const pkgPath = findPackageJsonUp(execDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from execPath:", err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
102
src/hooks/compaction-context-injector/index.test.ts
Normal file
102
src/hooks/compaction-context-injector/index.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock dependencies before importing
|
||||
const mockInjectHookMessage = mock(() => true)
|
||||
mock.module("../../features/hook-message-injector", () => ({
|
||||
injectHookMessage: mockInjectHookMessage,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/system-directive", () => ({
|
||||
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
|
||||
SystemDirectiveTypes: {
|
||||
TODO_CONTINUATION: "TODO CONTINUATION",
|
||||
RALPH_LOOP: "RALPH LOOP",
|
||||
BOULDER_CONTINUATION: "BOULDER CONTINUATION",
|
||||
DELEGATION_REQUIRED: "DELEGATION REQUIRED",
|
||||
SINGLE_TASK_ONLY: "SINGLE TASK ONLY",
|
||||
COMPACTION_CONTEXT: "COMPACTION CONTEXT",
|
||||
CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR",
|
||||
PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY",
|
||||
},
|
||||
}))
|
||||
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
import type { SummarizeContext } from "./index"
|
||||
|
||||
describe("createCompactionContextInjector", () => {
|
||||
beforeEach(() => {
|
||||
mockInjectHookMessage.mockClear()
|
||||
})
|
||||
|
||||
describe("Agent Verification State preservation", () => {
|
||||
it("includes Agent Verification State section in compaction prompt", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.85,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1)
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Agent Verification State")
|
||||
expect(injectedPrompt).toContain("Current Agent")
|
||||
expect(injectedPrompt).toContain("Verification Progress")
|
||||
})
|
||||
|
||||
it("includes Momus-specific context for reviewer agents", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.9,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Previous Rejections")
|
||||
expect(injectedPrompt).toContain("Acceptance Status")
|
||||
expect(injectedPrompt).toContain("reviewer agents")
|
||||
})
|
||||
|
||||
it("preserves file verification progress in compaction prompt", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.95,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Pending Verifications")
|
||||
expect(injectedPrompt).toContain("Files already verified")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,15 @@ When summarizing this session, you MUST include the following sections in your s
|
||||
- User's explicit restrictions or preferences
|
||||
- Anti-patterns identified during the session
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
|
||||
@@ -33,3 +33,4 @@ export { createStartWorkHook } from "./start-work";
|
||||
export { createAtlasHook } from "./atlas";
|
||||
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
|
||||
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
|
||||
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
|
||||
|
||||
@@ -55,7 +55,7 @@ You ARE the planner. Your job: create bulletproof work plans.
|
||||
* Determines if the agent is a planner-type agent.
|
||||
* Planner agents should NOT be told to call plan agent (they ARE the planner).
|
||||
*/
|
||||
function isPlannerAgent(agentName?: string): boolean {
|
||||
export function isPlannerAgent(agentName?: string): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase()
|
||||
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
|
||||
@@ -166,28 +166,52 @@ delegate_task(agent="oracle", prompt="Review my approach: [describe plan]")
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## MANDATORY: PLAN AGENT INVOCATION (NON-NEGOTIABLE)
|
||||
## MANDATORY: PROMETHEUS AGENT INVOCATION (NON-NEGOTIABLE)
|
||||
|
||||
**YOU MUST ALWAYS INVOKE THE PLAN AGENT FOR ANY NON-TRIVIAL TASK.**
|
||||
**YOU MUST ALWAYS INVOKE PROMETHEUS (THE PLANNER) FOR ANY NON-TRIVIAL TASK.**
|
||||
|
||||
| Condition | Action |
|
||||
|-----------|--------|
|
||||
| Task has 2+ steps | MUST call Plan agent |
|
||||
| Task scope unclear | MUST call Plan agent |
|
||||
| Implementation required | MUST call Plan agent |
|
||||
| Architecture decision needed | MUST call Plan agent |
|
||||
| Task has 2+ steps | MUST call Prometheus |
|
||||
| Task scope unclear | MUST call Prometheus |
|
||||
| Implementation required | MUST call Prometheus |
|
||||
| Architecture decision needed | MUST call Prometheus |
|
||||
|
||||
\`\`\`
|
||||
delegate_task(subagent_type="plan", prompt="<gathered context + user request>")
|
||||
delegate_task(subagent_type="prometheus", prompt="<gathered context + user request>")
|
||||
\`\`\`
|
||||
|
||||
**WHY THIS IS MANDATORY:**
|
||||
- Plan agent analyzes dependencies and parallel execution opportunities
|
||||
- Plan agent recommends CATEGORY + SKILLS for each task
|
||||
- Plan agent ensures nothing is missed
|
||||
**WHY PROMETHEUS IS MANDATORY:**
|
||||
- Prometheus analyzes dependencies and parallel execution opportunities
|
||||
- Prometheus recommends CATEGORY + SKILLS for each task (in TL;DR + per-task)
|
||||
- Prometheus ensures nothing is missed with structured work plans
|
||||
- YOU are an orchestrator, NOT an implementer
|
||||
|
||||
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
|
||||
### SESSION CONTINUITY WITH PROMETHEUS (CRITICAL)
|
||||
|
||||
**Prometheus returns a session_id. USE IT for follow-up interactions.**
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Prometheus asks clarifying questions | \`delegate_task(session_id="{returned_session_id}", prompt="<your answer>")\` |
|
||||
| Need to refine the plan | \`delegate_task(session_id="{returned_session_id}", prompt="Please adjust: <feedback>")\` |
|
||||
| Plan needs more detail | \`delegate_task(session_id="{returned_session_id}", prompt="Add more detail to Task N")\` |
|
||||
|
||||
**WHY SESSION_ID IS CRITICAL:**
|
||||
- Prometheus retains FULL conversation context
|
||||
- No repeated exploration or context gathering
|
||||
- Saves 70%+ tokens on follow-ups
|
||||
- Maintains interview continuity until plan is finalized
|
||||
|
||||
\`\`\`
|
||||
// WRONG: Starting fresh loses all context
|
||||
delegate_task(subagent_type="prometheus", prompt="Here's more info...")
|
||||
|
||||
// CORRECT: Resume preserves everything
|
||||
delegate_task(session_id="ses_abc123", prompt="Here's my answer to your question: ...")
|
||||
\`\`\`
|
||||
|
||||
**FAILURE TO CALL PROMETHEUS = INCOMPLETE WORK.**
|
||||
|
||||
---
|
||||
|
||||
@@ -259,17 +283,25 @@ delegate_task(..., run_in_background=true) // task_id_3
|
||||
delegate_task(subagent_type="librarian", run_in_background=true, prompt="...")
|
||||
\`\`\`
|
||||
|
||||
2. **INVOKE PLAN AGENT** (MANDATORY for non-trivial tasks):
|
||||
2. **INVOKE PROMETHEUS** (MANDATORY for non-trivial tasks):
|
||||
\`\`\`
|
||||
delegate_task(subagent_type="plan", prompt="<context + request>")
|
||||
result = delegate_task(subagent_type="prometheus", prompt="<context + request>")
|
||||
// STORE the session_id for follow-ups!
|
||||
prometheus_session_id = result.session_id
|
||||
\`\`\`
|
||||
|
||||
3. **EXECUTE VIA DELEGATION** (category + skills):
|
||||
3. **ITERATE WITH PROMETHEUS** (if clarification needed):
|
||||
\`\`\`
|
||||
// Use session_id to continue the conversation
|
||||
delegate_task(session_id=prometheus_session_id, prompt="<answer to Prometheus's question>")
|
||||
\`\`\`
|
||||
|
||||
4. **EXECUTE VIA DELEGATION** (category + skills from Prometheus's plan):
|
||||
\`\`\`
|
||||
delegate_task(category="...", load_skills=[...], prompt="<task from plan>")
|
||||
\`\`\`
|
||||
|
||||
4. **VERIFY** against original requirements
|
||||
5. **VERIFY** against original requirements
|
||||
|
||||
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
|
||||
|
||||
@@ -343,8 +375,9 @@ Write these criteria explicitly. Share with user if scope is non-trivial.
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
1. EXPLORES + LIBRARIANS (background)
|
||||
2. GATHER -> delegate_task(subagent_type="plan", prompt="<context + request>")
|
||||
3. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS
|
||||
2. GATHER -> delegate_task(subagent_type="prometheus", prompt="<context + request>")
|
||||
3. ITERATE WITH PROMETHEUS (session_id resume) UNTIL PLAN IS FINALIZED
|
||||
4. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS (following Prometheus's plan)
|
||||
|
||||
NOW.
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should use planner-specific ultrawork message when agent is prometheus", async () => {
|
||||
test("should skip ultrawork injection when agent is prometheus", async () => {
|
||||
// #given - collector and prometheus agent
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -378,16 +378,15 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - ultrawork keyword detected with prometheus agent
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// #then - should use planner-specific message with "YOU ARE A PLANNER" content
|
||||
// #then - ultrawork should be skipped for planner agents, text unchanged
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toBe("ultrawork plan this feature")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("plan this feature")
|
||||
})
|
||||
|
||||
test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => {
|
||||
test("should skip ultrawork injection when agent name contains 'planner'", async () => {
|
||||
// #given - collector and agent with 'planner' in name
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -400,12 +399,11 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - ultrawork keyword detected with planner agent
|
||||
await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output)
|
||||
|
||||
// #then - should use planner-specific message
|
||||
// #then - ultrawork should be skipped, text unchanged
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("create a work plan")
|
||||
expect(textPart!.text).toBe("ulw create a work plan")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should use normal ultrawork message when agent is Sisyphus", async () => {
|
||||
@@ -452,7 +450,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
expect(textPart!.text).toContain("do something")
|
||||
})
|
||||
|
||||
test("should switch from planner to normal message when agent changes", async () => {
|
||||
test("should skip ultrawork for prometheus but inject for sisyphus", async () => {
|
||||
// #given - two sessions, one with prometheus, one with sisyphus
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -473,11 +471,9 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
}
|
||||
await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "sisyphus" }, sisyphusOutput)
|
||||
|
||||
// #then - each session should have the correct message type
|
||||
// #then - prometheus should have no injection, sisyphus should have normal ultrawork
|
||||
const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text")
|
||||
expect(prometheusTextPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(prometheusTextPart!.text).toContain("---")
|
||||
expect(prometheusTextPart!.text).toContain("plan")
|
||||
expect(prometheusTextPart!.text).toBe("ultrawork plan")
|
||||
|
||||
const sisyphusTextPart = sisyphusOutput.parts.find(p => p.type === "text")
|
||||
expect(sisyphusTextPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
@@ -514,7 +510,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
clearSessionAgent(sessionID)
|
||||
})
|
||||
|
||||
test("should fall back to input.agent when session state is empty", async () => {
|
||||
test("should fall back to input.agent when session state is empty and skip ultrawork for prometheus", async () => {
|
||||
// #given - no session state, only input.agent available
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -531,11 +527,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - hook receives input.agent="prometheus" with no session state
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// #then - should use prometheus from input.agent as fallback
|
||||
// #then - prometheus fallback from input.agent, ultrawork skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("plan this")
|
||||
expect(textPart!.text).toBe("ultrawork plan this")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { isPlannerAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { isSystemDirective } from "../../shared/system-directive"
|
||||
import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
@@ -33,6 +34,10 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), currentAgent)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
}
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -891,40 +891,40 @@ Original task: Build something`
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
// FIXME: Flaky in CI - times out intermittently
|
||||
test.skip("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
test("should not hang when session.messages() throws", async () => {
|
||||
// #given - API that throws (simulates timeout error)
|
||||
let apiCallCount = 0
|
||||
const errorMock = {
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async () => {
|
||||
// Simulate slow API (would hang without timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
return { data: [] }
|
||||
apiCallCount++
|
||||
throw new Error("API timeout")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createRalphLoopHook(slowMock as any, {
|
||||
const hook = createRalphLoopHook(errorMock as any, {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
apiTimeout: 100, // 100ms timeout for test
|
||||
apiTimeout: 100,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - session goes idle (API will timeout)
|
||||
// #when - session goes idle (API will throw)
|
||||
const startTime = Date.now()
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// #then - should complete within timeout + buffer (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
// #then - loop should continue (API timeout = no completion detected)
|
||||
// #then - should complete quickly (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
// #then - loop should continue (API error = no completion detected)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(apiCallCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
82
src/hooks/subagent-question-blocker/index.test.ts
Normal file
82
src/hooks/subagent-question-blocker/index.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createSubagentQuestionBlockerHook } from "./index"
|
||||
import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("createSubagentQuestionBlockerHook", () => {
|
||||
const hook = createSubagentQuestionBlockerHook()
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("tool.execute.before", () => {
|
||||
test("allows question tool for non-subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_main"
|
||||
const input = { tool: "question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("blocks question tool for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("blocks Question tool (case insensitive) for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "Question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("blocks AskUserQuestion tool for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "AskUserQuestion", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("ignores non-question tools for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "bash", sessionID, callID: "call_1" }
|
||||
const output = { args: { command: "ls" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
29
src/hooks/subagent-question-blocker/index.ts
Normal file
29
src/hooks/subagent-question-blocker/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createSubagentQuestionBlockerHook(): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "question" && toolName !== "askuserquestion") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!subagentSessions.has(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("[subagent-question-blocker] Blocking question tool call from subagent session", {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
"Question tool is disabled for subagent sessions. " +
|
||||
"Subagents should complete their work autonomously without asking questions to users. " +
|
||||
"If you need clarification, return to the parent agent with your findings and uncertainties."
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
16
src/index.ts
16
src/index.ts
@@ -34,6 +34,7 @@ import {
|
||||
createPrometheusMdOnlyHook,
|
||||
createSisyphusJuniorNotepadHook,
|
||||
createQuestionLabelTruncatorHook,
|
||||
createSubagentQuestionBlockerHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
@@ -77,7 +78,7 @@ import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
import { TmuxSessionManager } from "./features/tmux-subagent";
|
||||
import { type HookName } from "./config";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
@@ -224,6 +225,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const questionLabelTruncator = createQuestionLabelTruncatorHook();
|
||||
const subagentQuestionBlocker = createSubagentQuestionBlockerHook();
|
||||
|
||||
const taskResumeInfo = createTaskResumeInfoHook();
|
||||
|
||||
@@ -396,6 +398,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
await startWork?.["chat.message"]?.(input, output);
|
||||
|
||||
if (!hasConnectedProvidersCache()) {
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "⚠️ Provider Cache Missing",
|
||||
message: "Model filtering disabled. RESTART OpenCode to enable full functionality.",
|
||||
variant: "warning" as const,
|
||||
duration: 6000,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (ralphLoop) {
|
||||
const parts = (
|
||||
output as { parts?: Array<{ type: string; text?: string }> }
|
||||
@@ -555,6 +568,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await subagentQuestionBlocker["tool.execute.before"]?.(input, output);
|
||||
await questionLabelTruncator["tool.execute.before"]?.(input, output);
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await nonInteractiveEnv?.["tool.execute.before"](input, output);
|
||||
|
||||
@@ -21,7 +21,7 @@ mcp/
|
||||
| Name | URL | Purpose | Auth |
|
||||
|------|-----|---------|------|
|
||||
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | None |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
|
||||
| grep_app | mcp.grep.app | GitHub code search | None |
|
||||
|
||||
## THREE-TIER MCP SYSTEM
|
||||
@@ -61,4 +61,5 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
|
||||
|
||||
- **Remote only**: HTTP/SSE, no stdio
|
||||
- **Disable**: User can set `disabled_mcps: ["name"]` in config
|
||||
- **Exa**: Requires `EXA_API_KEY` env var
|
||||
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var
|
||||
- **Exa**: Optional auth using `EXA_API_KEY` env var
|
||||
|
||||
@@ -2,5 +2,9 @@ export const context7 = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
enabled: true,
|
||||
headers: process.env.CONTEXT7_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` }
|
||||
: undefined,
|
||||
// Disable OAuth auto-detection - Context7 uses API key header, not OAuth
|
||||
oauth: false as const,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,185 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { resolveCategoryConfig } from "./config-handler"
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
|
||||
import type { CategoryConfig } from "../config/schema"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
mock.module("../agents", () => ({
|
||||
createBuiltinAgents: async () => ({
|
||||
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
||||
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../agents/sisyphus-junior", () => ({
|
||||
createSisyphusJuniorAgentWithOverrides: () => ({
|
||||
name: "sisyphus-junior",
|
||||
prompt: "test",
|
||||
mode: "subagent",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-command-loader", () => ({
|
||||
loadUserCommands: async () => ({}),
|
||||
loadProjectCommands: async () => ({}),
|
||||
loadOpencodeGlobalCommands: async () => ({}),
|
||||
loadOpencodeProjectCommands: async () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/builtin-commands", () => ({
|
||||
loadBuiltinCommands: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/opencode-skill-loader", () => ({
|
||||
loadUserSkills: async () => ({}),
|
||||
loadProjectSkills: async () => ({}),
|
||||
loadOpencodeGlobalSkills: async () => ({}),
|
||||
loadOpencodeProjectSkills: async () => ({}),
|
||||
discoverUserClaudeSkills: async () => [],
|
||||
discoverProjectClaudeSkills: async () => [],
|
||||
discoverOpencodeGlobalSkills: async () => [],
|
||||
discoverOpencodeProjectSkills: async () => [],
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-agent-loader", () => ({
|
||||
loadUserAgents: () => ({}),
|
||||
loadProjectAgents: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-mcp-loader", () => ({
|
||||
loadMcpConfigs: async () => ({ servers: {} }),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-plugin-loader", () => ({
|
||||
loadAllPluginComponents: async () => ({
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../mcp", () => ({
|
||||
createBuiltinMcps: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../shared", () => ({
|
||||
log: () => {},
|
||||
fetchAvailableModels: async () => new Set(["anthropic/claude-opus-4-5"]),
|
||||
readConnectedProvidersCache: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../shared/opencode-config-dir", () => ({
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
global: "/tmp/.config/opencode",
|
||||
project: "/tmp/.opencode",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../shared/permission-compat", () => ({
|
||||
migrateAgentConfig: (config: Record<string, unknown>) => config,
|
||||
}))
|
||||
|
||||
mock.module("../shared/migration", () => ({
|
||||
AGENT_NAME_MAP: {},
|
||||
}))
|
||||
|
||||
mock.module("../shared/model-resolver", () => ({
|
||||
resolveModelWithFallback: () => ({ model: "anthropic/claude-opus-4-5" }),
|
||||
}))
|
||||
|
||||
mock.module("../shared/model-requirements", () => ({
|
||||
AGENT_MODEL_REQUIREMENTS: {
|
||||
sisyphus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
oracle: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
librarian: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
explore: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"multimodal-looker": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
prometheus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
metis: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
momus: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
atlas: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
},
|
||||
CATEGORY_MODEL_REQUIREMENTS: {
|
||||
"visual-engineering": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
ultrabrain: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex" }] },
|
||||
artistry: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
quick: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"unspecified-low": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
"unspecified-high": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
writing: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
},
|
||||
}))
|
||||
|
||||
describe("Plan agent demote behavior", () => {
|
||||
test("plan agent should be demoted to subagent mode when replacePlan is true", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {
|
||||
plan: {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
prompt: "original plan prompt",
|
||||
},
|
||||
},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then
|
||||
const agents = config.agent as Record<string, { mode?: string; name?: string }>
|
||||
expect(agents.plan).toBeDefined()
|
||||
expect(agents.plan.mode).toBe("subagent")
|
||||
expect(agents.plan.name).toBe("plan")
|
||||
})
|
||||
|
||||
test("prometheus should have mode 'all' to be callable via delegate_task", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then
|
||||
const agents = config.agent as Record<string, { mode?: string }>
|
||||
expect(agents.prometheus).toBeDefined()
|
||||
expect(agents.prometheus.mode).toBe("all")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Prometheus category config resolution", () => {
|
||||
test("resolves ultrabrain category config", () => {
|
||||
|
||||
@@ -254,7 +254,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
name: "prometheus",
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
...(variantToUse ? { variant: variantToUse } : {}),
|
||||
mode: "primary" as const,
|
||||
mode: "all" as const,
|
||||
prompt: PROMETHEUS_SYSTEM_PROMPT,
|
||||
permission: PROMETHEUS_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
|
||||
@@ -307,7 +307,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan && agentConfig["prometheus"]
|
||||
? { ...agentConfig["prometheus"], name: "plan", mode: "subagent" as const }
|
||||
? {
|
||||
...agentConfig["prometheus"],
|
||||
name: "plan",
|
||||
mode: "subagent" as const
|
||||
}
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
@@ -381,8 +385,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
@@ -118,6 +118,161 @@ describe("external-plugin-detector", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("false positive prevention", () => {
|
||||
test("should NOT match my-opencode-notifier-fork (suffix variation)", () => {
|
||||
// #given - plugin with similar name but different suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["my-opencode-notifier-fork"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => {
|
||||
// #given - plugin path containing similar substring
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match opencode-notifier-extended (prefix match but different package)", () => {
|
||||
// #given - plugin with prefix match but extended name
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier-extended"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should match opencode-notifier exactly", () => {
|
||||
// #given - exact match
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match opencode-notifier@1.2.3 (version suffix)", () => {
|
||||
// #given - version suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match @mohak34/opencode-notifier (scoped package)", () => {
|
||||
// #given - scoped package
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toContain("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier (npm prefix)", () => {
|
||||
// #given - npm prefix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => {
|
||||
// #given - npm prefix with version
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match file:///path/to/opencode-notifier (file path)", () => {
|
||||
// #given - file path
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getNotificationConflictWarning", () => {
|
||||
test("should generate warning message with plugin name", () => {
|
||||
// #when
|
||||
|
||||
@@ -71,14 +71,19 @@ function loadOpencodePlugins(directory: string): string[] {
|
||||
function matchesNotificationPlugin(entry: string): string | null {
|
||||
const normalized = entry.toLowerCase()
|
||||
for (const known of KNOWN_NOTIFICATION_PLUGINS) {
|
||||
if (
|
||||
normalized === known ||
|
||||
normalized.startsWith(`${known}@`) ||
|
||||
normalized.includes(`/${known}`) ||
|
||||
normalized.endsWith(`/${known}`)
|
||||
) {
|
||||
return known
|
||||
}
|
||||
// Exact match
|
||||
if (normalized === known) return known
|
||||
// Version suffix: "opencode-notifier@1.2.3"
|
||||
if (normalized.startsWith(`${known}@`)) return known
|
||||
// Scoped package: "@mohak34/opencode-notifier" or "@mohak34/opencode-notifier@1.2.3"
|
||||
if (normalized === `@mohak34/${known}` || normalized.startsWith(`@mohak34/${known}@`)) return known
|
||||
// npm: prefix
|
||||
if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known
|
||||
// file:// path ending exactly with package name
|
||||
if (normalized.startsWith("file://") && (
|
||||
normalized.endsWith(`/${known}`) ||
|
||||
normalized.endsWith(`\\${known}`)
|
||||
)) return known
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
|
||||
import * as logger from "./logger"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
|
||||
describe("resolveModel", () => {
|
||||
describe("priority chain", () => {
|
||||
@@ -336,8 +337,48 @@ describe("resolveModelWithFallback", () => {
|
||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||
})
|
||||
|
||||
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
|
||||
// #given - empty availableModels simulates CI environment without model cache
|
||||
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
|
||||
// #given - both model cache and connected-providers cache are missing (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined, // no system default configured
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should return undefined to let OpenCode use Provider.defaultModel()
|
||||
expect(result).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses connected provider when availableModels empty but connected providers cache exists", () => {
|
||||
// #given - model cache missing but connected-providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use openai (second provider) since anthropic not in connected cache
|
||||
expect(result!.model).toBe("openai/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
|
||||
// #given - no cache but system default is configured
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
@@ -349,9 +390,10 @@ describe("resolveModelWithFallback", () => {
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use first fallback entry, not system default
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
// #then - should fall through to system default
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("returns system default when fallbackChain is not provided", () => {
|
||||
|
||||
@@ -58,25 +58,26 @@ export function resolveModelWithFallback(
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet === null || connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
hasConnectedCache: connectedSet !== null
|
||||
})
|
||||
return { model, source: "provider-fallback", variant: entry.variant }
|
||||
// When no cache exists at all, skip fallback chain and fall through to system default
|
||||
// This allows OpenCode to use Provider.defaultModel() as the final fallback
|
||||
if (connectedSet === null) {
|
||||
log("No cache available, skipping fallback chain to use system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return { model, source: "provider-fallback", variant: entry.variant }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No matching provider in connected cache, falling through to system default")
|
||||
}
|
||||
const firstEntry = fallbackChain[0]
|
||||
const firstProvider = firstEntry.providers[0]
|
||||
const model = `${firstProvider}/${firstEntry.model}`
|
||||
log("Model resolved via fallback chain (no cache at all, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
|
||||
return { model, source: "provider-fallback", variant: firstEntry.variant }
|
||||
}
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
|
||||
39
src/tools/delegate-task/timing.ts
Normal file
39
src/tools/delegate-task/timing.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
let POLL_INTERVAL_MS = 500
|
||||
let MIN_STABILITY_TIME_MS = 10000
|
||||
let STABILITY_POLLS_REQUIRED = 3
|
||||
let WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
let MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
let SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
|
||||
export function getTimingConfig() {
|
||||
return {
|
||||
POLL_INTERVAL_MS,
|
||||
MIN_STABILITY_TIME_MS,
|
||||
STABILITY_POLLS_REQUIRED,
|
||||
WAIT_FOR_SESSION_INTERVAL_MS,
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS,
|
||||
MAX_POLL_TIME_MS,
|
||||
SESSION_CONTINUATION_STABILITY_MS,
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetTimingConfig(): void {
|
||||
POLL_INTERVAL_MS = 500
|
||||
MIN_STABILITY_TIME_MS = 10000
|
||||
STABILITY_POLLS_REQUIRED = 3
|
||||
WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
}
|
||||
|
||||
export function __setTimingConfig(overrides: Partial<ReturnType<typeof getTimingConfig>>): void {
|
||||
if (overrides.POLL_INTERVAL_MS !== undefined) POLL_INTERVAL_MS = overrides.POLL_INTERVAL_MS
|
||||
if (overrides.MIN_STABILITY_TIME_MS !== undefined) MIN_STABILITY_TIME_MS = overrides.MIN_STABILITY_TIME_MS
|
||||
if (overrides.STABILITY_POLLS_REQUIRED !== undefined) STABILITY_POLLS_REQUIRED = overrides.STABILITY_POLLS_REQUIRED
|
||||
if (overrides.WAIT_FOR_SESSION_INTERVAL_MS !== undefined) WAIT_FOR_SESSION_INTERVAL_MS = overrides.WAIT_FOR_SESSION_INTERVAL_MS
|
||||
if (overrides.WAIT_FOR_SESSION_TIMEOUT_MS !== undefined) WAIT_FOR_SESSION_TIMEOUT_MS = overrides.WAIT_FOR_SESSION_TIMEOUT_MS
|
||||
if (overrides.MAX_POLL_TIME_MS !== undefined) MAX_POLL_TIME_MS = overrides.MAX_POLL_TIME_MS
|
||||
if (overrides.SESSION_CONTINUATION_STABILITY_MS !== undefined) SESSION_CONTINUATION_STABILITY_MS = overrides.SESSION_CONTINUATION_STABILITY_MS
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
|
||||
import { resolveCategoryConfig } from "./tools"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { __resetModelCache } from "../../shared/model-availability"
|
||||
import { clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { __setTimingConfig, __resetTimingConfig } from "./timing"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
// Test constants - systemDefaultModel is required by resolveCategoryConfig
|
||||
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
describe("sisyphus-task", () => {
|
||||
let cacheSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
clearSkillCache()
|
||||
__setTimingConfig({
|
||||
POLL_INTERVAL_MS: 10,
|
||||
MIN_STABILITY_TIME_MS: 50,
|
||||
STABILITY_POLLS_REQUIRED: 1,
|
||||
WAIT_FOR_SESSION_INTERVAL_MS: 10,
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS: 1000,
|
||||
MAX_POLL_TIME_MS: 2000,
|
||||
SESSION_CONTINUATION_STABILITY_MS: 50,
|
||||
})
|
||||
cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic", "google", "openai"])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
__resetTimingConfig()
|
||||
cacheSpy?.mockRestore()
|
||||
})
|
||||
|
||||
describe("DEFAULT_CATEGORIES", () => {
|
||||
@@ -201,6 +219,56 @@ describe("sisyphus-task", () => {
|
||||
// #then proceeds without error - uses fallback chain
|
||||
expect(result).not.toContain("oh-my-opencode requires a default model")
|
||||
})
|
||||
|
||||
test("returns clear error when no model can be resolved", async () => {
|
||||
// #given - custom category with no model, no systemDefaultModel, no available models
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({ id: "task-123" }) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) }, // No model configured
|
||||
model: { list: async () => [] }, // No available models
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
// Custom category with no model defined
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
userCategories: {
|
||||
"custom-no-model": { temperature: 0.5 }, // No model field
|
||||
},
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when delegating with a custom category that has no model
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "custom-no-model",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then returns clear error message with configuration guidance
|
||||
expect(result).toContain("Model not configured")
|
||||
expect(result).toContain("custom-no-model")
|
||||
expect(result).toContain("Configure in one of")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveCategoryConfig", () => {
|
||||
@@ -533,12 +601,12 @@ describe("sisyphus-task", () => {
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - variant MUST be "max" from DEFAULT_CATEGORIES
|
||||
// #then - variant MUST be "max" from DEFAULT_CATEGORIES (passed as separate field)
|
||||
expect(promptBody.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-5",
|
||||
variant: "max",
|
||||
})
|
||||
expect(promptBody.variant).toBe("max")
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { CategoryConfig, CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, PLAN_AGENT_SYSTEM_PREPEND, isPlanAgent } from "./constants"
|
||||
import { getTimingConfig } from "./timing"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
@@ -409,9 +410,10 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
}
|
||||
|
||||
// Wait for message stability after prompt completes
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MIN_STABILITY_TIME_MS = 5000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const timing = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = timing.POLL_INTERVAL_MS
|
||||
const MIN_STABILITY_TIME_MS = timing.SESSION_CONTINUATION_STABILITY_MS
|
||||
const STABILITY_POLLS_REQUIRED = timing.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
@@ -573,13 +575,26 @@ To continue this session: session_id="${args.session_id}"`
|
||||
}
|
||||
|
||||
agentToUse = SISYPHUS_JUNIOR_AGENT
|
||||
if (!categoryModel && actualModel) {
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel ?? undefined
|
||||
}
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
if (!categoryModel && actualModel) {
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel ?? undefined
|
||||
}
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
|
||||
if (!categoryModel && !actualModel) {
|
||||
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
|
||||
return `Model not configured for category "${args.category}".
|
||||
|
||||
Configure in one of:
|
||||
1. OpenCode: Set "model" in opencode.json
|
||||
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
|
||||
3. Provider: Connect a provider with available models
|
||||
|
||||
Current category: ${args.category}
|
||||
Available categories: ${categoryNames.join(", ")}`
|
||||
}
|
||||
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
|
||||
// Handle both boolean false and string "false" due to potential serialization
|
||||
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
|
||||
|
||||
@@ -649,10 +664,11 @@ To continue this session: session_id="${args.session_id}"`
|
||||
const startTime = new Date()
|
||||
|
||||
// Poll for completion (same logic as sync mode)
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const timingCfg = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = timingCfg.POLL_INTERVAL_MS
|
||||
const MAX_POLL_TIME_MS = timingCfg.MAX_POLL_TIME_MS
|
||||
const MIN_STABILITY_TIME_MS = timingCfg.MIN_STABILITY_TIME_MS
|
||||
const STABILITY_POLLS_REQUIRED = timingCfg.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
@@ -851,7 +867,10 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
body: {
|
||||
parentID: ctx.sessionID,
|
||||
title: `Task: ${args.description}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -920,7 +939,8 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(categoryModel ? { model: categoryModel } : {}),
|
||||
...(categoryModel ? { model: { providerID: categoryModel.providerID, modelID: categoryModel.modelID } } : {}),
|
||||
...(categoryModel?.variant ? { variant: categoryModel.variant } : {}),
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
@@ -948,10 +968,11 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
|
||||
// Poll for session completion with stability detection
|
||||
// The session may show as "idle" before messages appear, so we also check message stability
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const syncTiming = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = syncTiming.POLL_INTERVAL_MS
|
||||
const MAX_POLL_TIME_MS = syncTiming.MAX_POLL_TIME_MS
|
||||
const MIN_STABILITY_TIME_MS = syncTiming.MIN_STABILITY_TIME_MS
|
||||
const STABILITY_POLLS_REQUIRED = syncTiming.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
Reference in New Issue
Block a user