Compare commits
42 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c562a95d5 | ||
|
|
c2247aec60 | ||
|
|
1c9588ff33 | ||
|
|
5d73ac819d | ||
|
|
dfc57d0426 | ||
|
|
12c9029ed7 | ||
|
|
91060c35ab | ||
|
|
90292db4c4 | ||
|
|
cc4deed8ee | ||
|
|
4e4288807d | ||
|
|
629a4d3e1b | ||
|
|
8806ed17dc | ||
|
|
e2f8729731 | ||
|
|
bee8b3736d | ||
|
|
37e1a065d8 | ||
|
|
fc47a7a490 | ||
|
|
9b12e2a9b5 | ||
|
|
3062277a99 | ||
|
|
7093583ec5 | ||
|
|
ec61df8c17 | ||
|
|
6312d2da52 | ||
|
|
810dd93da2 | ||
|
|
1a901a50ac | ||
|
|
f8155e7d45 | ||
|
|
39d2d44e22 | ||
|
|
15c4637e0a | ||
|
|
262c7118da | ||
|
|
599fad0e86 | ||
|
|
afbdf69037 | ||
|
|
af9beee83c | ||
|
|
6973a75bf2 | ||
|
|
c6d6bd197e | ||
|
|
57b10439a4 | ||
|
|
6dfe091a88 | ||
|
|
75158caded | ||
|
|
e16bbbcc05 | ||
|
|
ab3e622baa | ||
|
|
f4348885f2 | ||
|
|
2c81c8e58e | ||
|
|
3268782730 | ||
|
|
be9d6c0061 | ||
|
|
45fe9578ec |
7
.github/workflows/publish-platform.yml
vendored
7
.github/workflows/publish-platform.yml
vendored
@@ -29,7 +29,12 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish-platform:
|
||||
runs-on: ubuntu-latest
|
||||
# Use windows-latest for Windows to avoid cross-compilation segfault (oven-sh/bun#18416)
|
||||
# Fixes: #873, #844
|
||||
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
|
||||
@@ -35,6 +35,8 @@ You are the release manager for oh-my-opencode. Execute the FULL publish workflo
|
||||
{ "id": "draft-release-notes", "content": "Draft enhanced release notes content", "status": "pending", "priority": "high" },
|
||||
{ "id": "update-release-notes", "content": "Update GitHub release with enhanced notes", "status": "pending", "priority": "high" },
|
||||
{ "id": "verify-npm", "content": "Verify npm package published successfully", "status": "pending", "priority": "high" },
|
||||
{ "id": "wait-platform-workflow", "content": "Wait for publish-platform workflow completion", "status": "pending", "priority": "high" },
|
||||
{ "id": "verify-platform-binaries", "content": "Verify all 7 platform binary packages published", "status": "pending", "priority": "high" },
|
||||
{ "id": "final-confirmation", "content": "Final confirmation to user with links", "status": "pending", "priority": "low" }
|
||||
]
|
||||
```
|
||||
@@ -219,12 +221,64 @@ Compare with expected version. If not matching after 2 minutes, warn user about
|
||||
|
||||
---
|
||||
|
||||
## STEP 8.5: WAIT FOR PLATFORM WORKFLOW COMPLETION
|
||||
|
||||
The main publish workflow triggers a separate `publish-platform` workflow for platform-specific binaries.
|
||||
|
||||
1. Find the publish-platform workflow run triggered by the main workflow:
|
||||
```bash
|
||||
gh run list --workflow=publish-platform --limit=1 --json databaseId,status,conclusion --jq '.[0]'
|
||||
```
|
||||
|
||||
2. Poll workflow status every 30 seconds until completion:
|
||||
```bash
|
||||
gh run view {platform_run_id} --json status,conclusion --jq '{status: .status, conclusion: .conclusion}'
|
||||
```
|
||||
|
||||
**IMPORTANT: Use polling loop, NOT sleep commands.**
|
||||
|
||||
If conclusion is `failure`, show error logs:
|
||||
```bash
|
||||
gh run view {platform_run_id} --log-failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 8.6: VERIFY PLATFORM BINARY PACKAGES
|
||||
|
||||
After publish-platform workflow completes, verify all 7 platform packages are published:
|
||||
|
||||
```bash
|
||||
PLATFORMS="darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64"
|
||||
for PLATFORM in $PLATFORMS; do
|
||||
npm view "oh-my-opencode-${PLATFORM}" version
|
||||
done
|
||||
```
|
||||
|
||||
All 7 packages should show the same version as the main package (`${NEW_VERSION}`).
|
||||
|
||||
**Expected packages:**
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `oh-my-opencode-darwin-arm64` | macOS Apple Silicon |
|
||||
| `oh-my-opencode-darwin-x64` | macOS Intel |
|
||||
| `oh-my-opencode-linux-x64` | Linux x64 (glibc) |
|
||||
| `oh-my-opencode-linux-arm64` | Linux ARM64 (glibc) |
|
||||
| `oh-my-opencode-linux-x64-musl` | Linux x64 (musl/Alpine) |
|
||||
| `oh-my-opencode-linux-arm64-musl` | Linux ARM64 (musl/Alpine) |
|
||||
| `oh-my-opencode-windows-x64` | Windows x64 |
|
||||
|
||||
If any platform package version doesn't match, warn the user and suggest checking the publish-platform workflow logs.
|
||||
|
||||
---
|
||||
|
||||
## STEP 9: FINAL CONFIRMATION
|
||||
|
||||
Report success to user with:
|
||||
- New version number
|
||||
- GitHub release URL: https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v{version}
|
||||
- npm package URL: https://www.npmjs.com/package/oh-my-opencode
|
||||
- Platform packages status: List all 7 platform packages with their versions
|
||||
|
||||
---
|
||||
|
||||
@@ -234,6 +288,8 @@ Report success to user with:
|
||||
- **Release not found**: Wait and retry, may be propagation delay
|
||||
- **npm not updated**: npm can take 1-5 minutes to propagate, inform user
|
||||
- **Permission denied**: User may need to re-authenticate with `gh auth login`
|
||||
- **Platform workflow fails**: Show logs from publish-platform workflow, check which platform failed
|
||||
- **Platform package missing**: Some platforms may fail due to cross-compilation issues, suggest re-running publish-platform workflow manually
|
||||
|
||||
## LANGUAGE
|
||||
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -1,12 +1,12 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-23T02:09:00+09:00
|
||||
**Commit:** 0e18efc7
|
||||
**Generated:** 2026-01-23T15:59:00+09:00
|
||||
**Commit:** 599fad0e
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok, GLM-4.7). 31 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 10 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3 Flash, Grok Code, GLM-4.7). 31 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 10 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
@@ -21,7 +21,7 @@ oh-my-opencode/
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (590 lines)
|
||||
│ └── index.ts # Main plugin entry (593 lines)
|
||||
├── script/ # build-schema.ts, build-binaries.ts
|
||||
├── packages/ # 7 platform-specific binaries
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
@@ -38,7 +38,7 @@ oh-my-opencode/
|
||||
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts (1335 lines) |
|
||||
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (771 lines) |
|
||||
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (773 lines) |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
@@ -90,7 +90,7 @@ oh-my-opencode/
|
||||
| oracle | openai/gpt-5.2 | Consultation, debugging |
|
||||
| librarian | opencode/glm-4.7-free | Docs, GitHub search |
|
||||
| explore | opencode/grok-code | Fast codebase grep |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
| multimodal-looker | google/gemini-3-flash-preview | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
|
||||
|
||||
## COMMANDS
|
||||
@@ -113,12 +113,12 @@ bun test # 90 test files
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/agents/atlas.ts` | 1383 | Orchestrator, 7-section delegation |
|
||||
| `src/features/background-agent/manager.ts` | 1335 | Task lifecycle, concurrency |
|
||||
| `src/features/builtin-skills/skills.ts` | 1203 | Skill definitions |
|
||||
| `src/agents/prometheus-prompt.ts` | 1196 | Planning agent |
|
||||
| `src/tools/delegate-task/tools.ts` | 1038 | Category-based delegation |
|
||||
| `src/hooks/atlas/index.ts` | 771 | Orchestrator hook |
|
||||
| `src/tools/delegate-task/tools.ts` | 1039 | Category-based delegation |
|
||||
| `src/hooks/atlas/index.ts` | 773 | Orchestrator hook |
|
||||
| `src/cli/config-manager.ts` | 641 | JSONC config parsing |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Sisyphus",
|
||||
"sisyphus",
|
||||
"prometheus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"Atlas"
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -345,7 +346,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sisyphus": {
|
||||
"sisyphus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -471,7 +472,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sisyphus-Junior": {
|
||||
"sisyphus-junior": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -723,7 +724,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Prometheus (Planner)": {
|
||||
"prometheus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -849,7 +850,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Metis (Plan Consultant)": {
|
||||
"metis": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -975,7 +976,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Momus (Plan Reviewer)": {
|
||||
"momus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -1605,7 +1606,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Atlas": {
|
||||
"atlas": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
Highly opinionated, but adjustable to taste.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Most users don't need to configure anything manually.** Run the interactive installer:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
```
|
||||
|
||||
It asks about your providers (Claude, OpenAI, Gemini, etc.) and generates optimal config automatically.
|
||||
|
||||
**Want to customize?** Here's the common patterns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Override specific agent models
|
||||
"agents": {
|
||||
"oracle": { "model": "openai/gpt-5.2" }, // Use GPT for debugging
|
||||
"librarian": { "model": "zai-coding-plan/glm-4.7" }, // Cheap model for research
|
||||
"explore": { "model": "opencode/grok-code" } // Free model for grep
|
||||
},
|
||||
|
||||
// Override category models (used by delegate_task)
|
||||
"categories": {
|
||||
"quick": { "model": "opencode/grok-code" }, // Fast/cheap for trivial tasks
|
||||
"visual-engineering": { "model": "google/gemini-3-pro-preview" } // Gemini for UI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Find available models:** Run `opencode models` to see all models in your environment.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
Config file locations (priority order):
|
||||
@@ -372,7 +405,7 @@ Each agent has a defined provider priority chain. The system tries providers in
|
||||
| **oracle** | `gpt-5.2` | openai → anthropic → google → github-copilot → opencode |
|
||||
| **librarian** | `glm-4.7-free` | opencode → github-copilot → anthropic |
|
||||
| **explore** | `grok-code` | opencode → anthropic → github-copilot |
|
||||
| **multimodal-looker** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash-preview` | google → anthropic → zai → openai → github-copilot → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
|
||||
@@ -14,7 +14,7 @@ Oh-My-OpenCode provides 10 specialized AI agents. Each has distinct expertise, o
|
||||
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
|
||||
| **librarian** | `opencode/glm-4.7-free` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Inspired by AmpCode. |
|
||||
| **explore** | `opencode/grok-code` | Fast codebase exploration and contextual grep. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash-preview` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
|
||||
|
||||
### Planning Agents
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ For complex or critical tasks, press **Tab** to switch to Prometheus (Planner) m
|
||||
|
||||
2. **Plan generation** - Based on the interview, Prometheus generates a detailed work plan with tasks, acceptance criteria, and guardrails. Optionally reviewed by Momus (plan reviewer) for high-accuracy validation.
|
||||
|
||||
3. **Run `/start-work`** - The Orchestrator-Sisyphus takes over:
|
||||
3. **Run `/start-work`** - The Atlas takes over:
|
||||
- Distributes tasks to specialized sub-agents
|
||||
- Verifies each task completion independently
|
||||
- Accumulates learnings across tasks
|
||||
@@ -84,7 +84,78 @@ The orchestrator is designed to execute work plans created by Prometheus. Using
|
||||
4. Run /start-work → Orchestrator executes
|
||||
```
|
||||
|
||||
**Prometheus and Orchestrator-Sisyphus are a pair. Always use them together.**
|
||||
**Prometheus and Atlas are a pair. Always use them together.**
|
||||
|
||||
---
|
||||
|
||||
## Model Configuration
|
||||
|
||||
Oh My OpenCode automatically configures models based on your available providers. You don't need to manually specify every model.
|
||||
|
||||
### How Models Are Determined
|
||||
|
||||
**1. At Installation Time (Interactive Installer)**
|
||||
|
||||
When you run `bunx oh-my-opencode install`, the installer asks which providers you have:
|
||||
- Claude Pro/Max subscription?
|
||||
- OpenAI/ChatGPT Plus?
|
||||
- Google Gemini?
|
||||
- GitHub Copilot?
|
||||
- OpenCode Zen?
|
||||
- Z.ai Coding Plan?
|
||||
|
||||
Based on your answers, it generates `~/.config/opencode/oh-my-opencode.json` with optimal model assignments for each agent and category.
|
||||
|
||||
**2. At Runtime (Fallback Chain)**
|
||||
|
||||
Each agent has a **provider priority chain**. The system tries providers in order until it finds an available model:
|
||||
|
||||
```
|
||||
Example: multimodal-looker
|
||||
google → anthropic → zai → openai → github-copilot → opencode
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
gemini haiku glm-4.6v gpt-5.2 fallback fallback
|
||||
```
|
||||
|
||||
If you have Gemini, it uses `google/gemini-3-flash-preview`. No Gemini but have Claude? Uses `anthropic/claude-haiku-4-5`. And so on.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
Here's a real-world config for a user with **Claude, OpenAI, Gemini, and Z.ai** all available:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
// Override specific agents only - rest use fallback chain
|
||||
"Atlas": { "model": "anthropic/claude-sonnet-4-5", "variant": "max" },
|
||||
"librarian": { "model": "zai-coding-plan/glm-4.7" },
|
||||
"explore": { "model": "opencode/grok-code" },
|
||||
"multimodal-looker": { "model": "zai-coding-plan/glm-4.6v" }
|
||||
},
|
||||
"categories": {
|
||||
// Override categories for cost optimization
|
||||
"quick": { "model": "opencode/grok-code" },
|
||||
"unspecified-low": { "model": "zai-coding-plan/glm-4.7" }
|
||||
},
|
||||
"experimental": {
|
||||
"aggressive_truncation": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- You only need to override what you want to change
|
||||
- Unspecified agents/categories use the automatic fallback chain
|
||||
- Mix providers freely (Claude for main work, Z.ai for cheap tasks, etc.)
|
||||
|
||||
### Finding Available Models
|
||||
|
||||
Run `opencode models` to see all available models in your environment. Model names follow the format `provider/model-name`.
|
||||
|
||||
### Learn More
|
||||
|
||||
For detailed configuration options including per-agent settings, category customization, and more, see the [Configuration Guide](../configurations.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Understanding the Orchestration System
|
||||
|
||||
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Orchestrator → Junior workflow creates high-quality, reliable code output.
|
||||
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Atlas → Junior workflow creates high-quality, reliable code output.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Execution["Execution Layer (Orchestrator)"]
|
||||
Orchestrator["⚡ Orchestrator-Sisyphus<br/>(Conductor)<br/>Claude Opus 4.5"]
|
||||
Orchestrator["⚡ Atlas<br/>(Conductor)<br/>Claude Opus 4.5"]
|
||||
end
|
||||
|
||||
subgraph Workers["Worker Layer (Specialized Agents)"]
|
||||
@@ -152,7 +152,7 @@ If REJECTED, Prometheus fixes issues and resubmits. **No maximum retry limit.**
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Execution (Orchestrator-Sisyphus)
|
||||
## Layer 2: Execution (Atlas)
|
||||
|
||||
### The Conductor Mindset
|
||||
|
||||
@@ -160,7 +160,7 @@ The Orchestrator is like an orchestra conductor: **it doesn't play instruments,
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Orchestrator["Orchestrator-Sisyphus"]
|
||||
subgraph Orchestrator["Atlas"]
|
||||
Read["1. Read Plan"]
|
||||
Analyze["2. Analyze Tasks"]
|
||||
Wisdom["3. Accumulate Wisdom"]
|
||||
@@ -352,7 +352,7 @@ delegate_task(
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Orchestrator as Orchestrator-Sisyphus
|
||||
participant Orchestrator as Atlas
|
||||
participant Junior as Sisyphus-Junior
|
||||
participant Notepad as .sisyphus/notepads/
|
||||
|
||||
@@ -392,7 +392,7 @@ sequenceDiagram
|
||||
### 1. Separation of Concerns
|
||||
|
||||
- **Planning** (Prometheus): High reasoning, interview, strategic thinking
|
||||
- **Orchestration** (Sisyphus): Coordination, verification, wisdom accumulation
|
||||
- **Orchestration** (Atlas): Coordination, verification, wisdom accumulation
|
||||
- **Execution** (Junior): Focused implementation, no distractions
|
||||
|
||||
### 2. Explicit Over Implicit
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|------------|----------|-------------|
|
||||
| **Simple** | Just prompt | Simple tasks, quick fixes, single-file changes |
|
||||
| **Complex + Lazy** | Just type `ulw` or `ultrawork` | Complex tasks where explaining context is tedious. Agent figures it out. |
|
||||
| **Complex + Precise** | `@plan` → `/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Sisyphus executes. |
|
||||
| **Complex + Precise** | `@plan` → `/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Atlas executes. |
|
||||
|
||||
**Decision Flow:**
|
||||
|
||||
```
|
||||
Is it a quick fix or simple task?
|
||||
└─ YES → Just prompt normally
|
||||
@@ -30,7 +31,7 @@ Traditional AI agents often mix planning and execution, leading to context pollu
|
||||
Oh-My-OpenCode solves this by clearly separating two roles:
|
||||
|
||||
1. **Prometheus (Planner)**: A pure strategist who never writes code. Establishes perfect plans through interviews and analysis.
|
||||
2. **Sisyphus (Executor)**: An orchestrator who executes plans. Delegates work to specialized agents and never stops until completion.
|
||||
2. **Atlas (Executor)**: An orchestrator who executes plans. Delegates work to specialized agents and never stops until completion.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,10 +53,10 @@ flowchart TD
|
||||
StartWork --> BoulderState[boulder.json]
|
||||
|
||||
subgraph Execution Phase
|
||||
BoulderState --> Sisyphus[Sisyphus<br>Orchestrator]
|
||||
Sisyphus --> Oracle[Oracle]
|
||||
Sisyphus --> Frontend[Frontend<br>Engineer]
|
||||
Sisyphus --> Explore[Explore]
|
||||
BoulderState --> Atlas[Atlas<br>Orchestrator]
|
||||
Atlas --> Oracle[Oracle]
|
||||
Atlas --> Frontend[Frontend<br>Engineer]
|
||||
Atlas --> Explore[Explore]
|
||||
end
|
||||
```
|
||||
|
||||
@@ -64,22 +65,26 @@ flowchart TD
|
||||
## 3. Key Components
|
||||
|
||||
### 🔮 Prometheus (The Planner)
|
||||
|
||||
- **Model**: `anthropic/claude-opus-4-5`
|
||||
- **Role**: Strategic planning, requirements interviews, work plan creation
|
||||
- **Constraint**: **READ-ONLY**. Can only create/modify markdown files within `.sisyphus/` directory.
|
||||
- **Characteristic**: Never writes code directly, focuses solely on "how to do it".
|
||||
|
||||
### 🦉 Metis (The Consultant)
|
||||
### 🦉 Metis (The Plan Consultant)
|
||||
|
||||
- **Role**: Pre-analysis and gap detection
|
||||
- **Function**: Identifies hidden user intent, prevents AI over-engineering, eliminates ambiguity.
|
||||
- **Workflow**: Metis consultation is mandatory before plan creation.
|
||||
|
||||
### ⚖️ Momus (The Reviewer)
|
||||
### ⚖️ Momus (The Plan Reviewer)
|
||||
|
||||
- **Role**: High-precision plan validation (High Accuracy Mode)
|
||||
- **Function**: Rejects and demands revisions until the plan is perfect.
|
||||
- **Trigger**: Activated when user requests "high accuracy".
|
||||
|
||||
### 🪨 Sisyphus (The Orchestrator)
|
||||
### ⚡ Atlas (The Plan Executor)
|
||||
|
||||
- **Model**: `anthropic/claude-opus-4-5` (Extended Thinking 32k)
|
||||
- **Role**: Execution and delegation
|
||||
- **Characteristic**: Doesn't do everything directly, actively delegates to specialized agents (Frontend, Librarian, etc.).
|
||||
@@ -89,6 +94,7 @@ flowchart TD
|
||||
## 4. Workflow
|
||||
|
||||
### Phase 1: Interview and Planning (Interview Mode)
|
||||
|
||||
Prometheus starts in **interview mode** by default. Instead of immediately creating a plan, it collects sufficient context.
|
||||
|
||||
1. **Intent Identification**: Classifies whether the user's request is Refactoring or New Feature.
|
||||
@@ -96,6 +102,7 @@ Prometheus starts in **interview mode** by default. Instead of immediately creat
|
||||
3. **Draft Creation**: Continuously records discussion content in `.sisyphus/drafts/`.
|
||||
|
||||
### Phase 2: Plan Generation
|
||||
|
||||
When the user requests "Make it a plan", plan generation begins.
|
||||
|
||||
1. **Metis Consultation**: Confirms any missed requirements or risk factors.
|
||||
@@ -103,10 +110,11 @@ When the user requests "Make it a plan", plan generation begins.
|
||||
3. **Handoff**: Once plan creation is complete, guides user to use `/start-work` command.
|
||||
|
||||
### Phase 3: Execution
|
||||
|
||||
When the user enters `/start-work`, the execution phase begins.
|
||||
|
||||
1. **State Management**: Creates `boulder.json` file to track current plan and session ID.
|
||||
2. **Task Execution**: Sisyphus reads the plan and processes TODOs one by one.
|
||||
2. **Task Execution**: Atlas reads the plan and processes TODOs one by one.
|
||||
3. **Delegation**: UI work is delegated to Frontend agent, complex logic to Oracle.
|
||||
4. **Continuity**: Even if the session is interrupted, work continues in the next session through `boulder.json`.
|
||||
|
||||
@@ -115,11 +123,15 @@ When the user enters `/start-work`, the execution phase begins.
|
||||
## 5. Commands and Usage
|
||||
|
||||
### `@plan [request]`
|
||||
|
||||
Invokes Prometheus to start a planning session.
|
||||
|
||||
- Example: `@plan "I want to refactor the authentication system to NextAuth"`
|
||||
|
||||
### `/start-work`
|
||||
|
||||
Executes the generated plan.
|
||||
|
||||
- Function: Finds plan in `.sisyphus/plans/` and enters execution mode.
|
||||
- If there's interrupted work, automatically resumes from where it left off.
|
||||
|
||||
@@ -132,7 +144,7 @@ You can control related features in `oh-my-opencode.json`.
|
||||
```jsonc
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false, // Enable Sisyphus orchestration (default: false)
|
||||
"disabled": false, // Enable Atlas orchestration (default: false)
|
||||
"planner_enabled": true, // Enable Prometheus (default: true)
|
||||
"replace_plan": true // Replace default plan agent with Prometheus (default: true)
|
||||
},
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"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.0.0-beta.13",
|
||||
"oh-my-opencode-darwin-x64": "3.0.0-beta.13",
|
||||
"oh-my-opencode-linux-arm64": "3.0.0-beta.13",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.13",
|
||||
"oh-my-opencode-linux-x64": "3.0.0-beta.13",
|
||||
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.13",
|
||||
"oh-my-opencode-windows-x64": "3.0.0-beta.13"
|
||||
"oh-my-opencode-darwin-arm64": "3.0.0-beta.14",
|
||||
"oh-my-opencode-darwin-x64": "3.0.0-beta.14",
|
||||
"oh-my-opencode-linux-arm64": "3.0.0-beta.14",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.14",
|
||||
"oh-my-opencode-linux-x64": "3.0.0-beta.14",
|
||||
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.14",
|
||||
"oh-my-opencode-windows-x64": "3.0.0-beta.14"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"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.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"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.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"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.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.0.0-beta.13",
|
||||
"version": "3.0.0-beta.14",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -711,6 +711,30 @@
|
||||
"created_at": "2026-01-22T12:39:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 989
|
||||
},
|
||||
{
|
||||
"name": "l3aro",
|
||||
"id": 25253808,
|
||||
"comment_id": 3786383804,
|
||||
"created_at": "2026-01-22T19:52:42Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 999
|
||||
},
|
||||
{
|
||||
"name": "Ssoon-m",
|
||||
"id": 89559826,
|
||||
"comment_id": 3788539617,
|
||||
"created_at": "2026-01-23T06:31:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1014
|
||||
},
|
||||
{
|
||||
"name": "veetase",
|
||||
"id": 2784250,
|
||||
"comment_id": 3789028002,
|
||||
"created_at": "2026-01-23T08:27:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 985
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```
|
||||
agents/
|
||||
├── atlas.ts # Master Orchestrator (1383 lines)
|
||||
├── atlas.ts # Master Orchestrator (543 lines)
|
||||
├── sisyphus.ts # Main prompt (615 lines)
|
||||
├── sisyphus-junior.ts # Delegated task executor
|
||||
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
|
||||
@@ -33,7 +33,7 @@ agents/
|
||||
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
|
||||
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search |
|
||||
| explore | opencode/grok-code | 0.1 | Fast contextual grep |
|
||||
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
|
||||
| multimodal-looker | google/gemini-3-flash-preview | 0.1 | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |
|
||||
|
||||
1340
src/agents/atlas.ts
1340
src/agents/atlas.ts
File diff suppressed because it is too large
Load Diff
@@ -319,8 +319,8 @@ Or should I just note down this single fix?"
|
||||
|
||||
**Research First:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="explore", prompt="Find all usages of [target] using lsp_find_references pattern...", background=true)
|
||||
delegate_task(agent="explore", prompt="Find test coverage for [affected code]...", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find all usages of [target] using lsp_find_references pattern...", run_in_background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find test coverage for [affected code]...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**Interview Focus:**
|
||||
@@ -343,9 +343,9 @@ delegate_task(agent="explore", prompt="Find test coverage for [affected code]...
|
||||
**Pre-Interview Research (MANDATORY):**
|
||||
\`\`\`typescript
|
||||
// Launch BEFORE asking user questions
|
||||
delegate_task(agent="explore", prompt="Find similar implementations in codebase...", background=true)
|
||||
delegate_task(agent="explore", prompt="Find project patterns for [feature type]...", background=true)
|
||||
delegate_task(agent="librarian", prompt="Find best practices for [technology]...", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find similar implementations in codebase...", run_in_background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find project patterns for [feature type]...", run_in_background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find best practices for [technology]...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**Interview Focus** (AFTER research):
|
||||
@@ -384,7 +384,7 @@ Based on your stack, I'd recommend NextAuth.js - it integrates well with Next.js
|
||||
|
||||
Run this check:
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="explore", prompt="Find test infrastructure: package.json test scripts, test config files (jest.config, vitest.config, pytest.ini, etc.), existing test files (*.test.*, *.spec.*, test_*). Report: 1) Does test infra exist? 2) What framework? 3) Example test file patterns.", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find test infrastructure: package.json test scripts, test config files (jest.config, vitest.config, pytest.ini, etc.), existing test files (*.test.*, *.spec.*, test_*). Report: 1) Does test infra exist? 2) What framework? 3) Example test file patterns.", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
#### Step 2: Ask the Test Question (MANDATORY)
|
||||
@@ -473,13 +473,13 @@ Add to draft immediately:
|
||||
|
||||
**Research First:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="explore", prompt="Find current system architecture and patterns...", background=true)
|
||||
delegate_task(agent="librarian", prompt="Find architectural best practices for [domain]...", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find current system architecture and patterns...", run_in_background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find architectural best practices for [domain]...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**Oracle Consultation** (recommend when stakes are high):
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="oracle", prompt="Architecture consultation needed: [context]...", background=false)
|
||||
delegate_task(subagent_type="oracle", prompt="Architecture consultation needed: [context]...", run_in_background=false)
|
||||
\`\`\`
|
||||
|
||||
**Interview Focus:**
|
||||
@@ -496,9 +496,9 @@ delegate_task(agent="oracle", prompt="Architecture consultation needed: [context
|
||||
|
||||
**Parallel Investigation:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="explore", prompt="Find how X is currently handled...", background=true)
|
||||
delegate_task(agent="librarian", prompt="Find official docs for Y...", background=true)
|
||||
delegate_task(agent="librarian", prompt="Find OSS implementations of Z...", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find how X is currently handled...", run_in_background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find official docs for Y...", run_in_background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find OSS implementations of Z...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**Interview Focus:**
|
||||
@@ -524,17 +524,17 @@ delegate_task(agent="librarian", prompt="Find OSS implementations of Z...", back
|
||||
|
||||
**For Understanding Codebase:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="explore", prompt="Find all files related to [topic]. Show patterns, conventions, and structure.", background=true)
|
||||
delegate_task(subagent_type="explore", prompt="Find all files related to [topic]. Show patterns, conventions, and structure.", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**For External Knowledge:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="librarian", prompt="Find official documentation for [library]. Focus on [specific feature] and best practices.", background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find official documentation for [library]. Focus on [specific feature] and best practices.", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
**For Implementation Examples:**
|
||||
\`\`\`typescript
|
||||
delegate_task(agent="librarian", prompt="Find open source implementations of [feature]. Look for production-quality examples.", background=true)
|
||||
delegate_task(subagent_type="librarian", prompt="Find open source implementations of [feature]. Look for production-quality examples.", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
## Interview Mode Anti-Patterns
|
||||
@@ -631,7 +631,7 @@ todoWrite([
|
||||
|
||||
\`\`\`typescript
|
||||
delegate_task(
|
||||
agent="Metis (Plan Consultant)",
|
||||
subagent_type="metis",
|
||||
prompt=\`Review this planning session before I generate the work plan:
|
||||
|
||||
**User's Goal**: {summarize what user wants}
|
||||
@@ -652,7 +652,7 @@ delegate_task(
|
||||
4. Assumptions I'm making that need validation
|
||||
5. Missing acceptance criteria
|
||||
6. Edge cases not addressed\`,
|
||||
background=false
|
||||
run_in_background=false
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
@@ -797,9 +797,9 @@ Question({
|
||||
// After generating initial plan
|
||||
while (true) {
|
||||
const result = delegate_task(
|
||||
agent="Momus (Plan Reviewer)",
|
||||
subagent_type="momus",
|
||||
prompt=".sisyphus/plans/{name}.md",
|
||||
background=false
|
||||
run_in_background=false
|
||||
)
|
||||
|
||||
if (result.verdict === "OKAY") {
|
||||
|
||||
@@ -205,6 +205,34 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
|
||||
**Vague prompts = rejected. Be exhaustive.**
|
||||
|
||||
### Session Continuity (MANDATORY)
|
||||
|
||||
Every \`delegate_task()\` output includes a session_id. **USE IT.**
|
||||
|
||||
**ALWAYS resume when:**
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Task failed/incomplete | \`resume="{session_id}", prompt="Fix: {specific error}"\` |
|
||||
| Follow-up question on result | \`resume="{session_id}", prompt="Also: {question}"\` |
|
||||
| Multi-turn with same agent | \`resume="{session_id}"\` - NEVER start fresh |
|
||||
| Verification failed | \`resume="{session_id}", prompt="Failed verification: {error}. Fix."\` |
|
||||
|
||||
**Why resume is CRITICAL:**
|
||||
- Subagent has FULL conversation context preserved
|
||||
- No repeated file reads, exploration, or setup
|
||||
- Saves 70%+ tokens on follow-ups
|
||||
- Subagent knows what it already tried/learned
|
||||
|
||||
\`\`\`typescript
|
||||
// WRONG: Starting fresh loses all context
|
||||
delegate_task(category="quick", prompt="Fix the type error in auth.ts...")
|
||||
|
||||
// CORRECT: Resume preserves everything
|
||||
delegate_task(resume="ses_abc123", prompt="Fix: Type error on line 42")
|
||||
\`\`\`
|
||||
|
||||
**After EVERY delegation, STORE the session_id for potential resume.**
|
||||
|
||||
### Code Changes:
|
||||
- Match existing patterns (if codebase is disciplined)
|
||||
- Propose approach first (if codebase is chaotic)
|
||||
|
||||
@@ -57,14 +57,14 @@ export function isGptModel(model: string): boolean {
|
||||
}
|
||||
|
||||
export type BuiltinAgentName =
|
||||
| "Sisyphus"
|
||||
| "sisyphus"
|
||||
| "oracle"
|
||||
| "librarian"
|
||||
| "explore"
|
||||
| "multimodal-looker"
|
||||
| "Metis (Plan Consultant)"
|
||||
| "Momus (Plan Reviewer)"
|
||||
| "Atlas"
|
||||
| "metis"
|
||||
| "momus"
|
||||
| "atlas"
|
||||
|
||||
export type OverridableAgentName =
|
||||
| "build"
|
||||
|
||||
@@ -12,46 +12,46 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.Sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.Sisyphus.reasoningEffort).toBeUndefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Sisyphus with GPT model override has reasoningEffort, no thinking", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
Sisyphus: { model: "github-copilot/gpt-5.2" },
|
||||
sisyphus: { model: "github-copilot/gpt-5.2" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
expect(agents.Sisyphus.reasoningEffort).toBe("medium")
|
||||
expect(agents.Sisyphus.thinking).toBeUndefined()
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
expect(agents.sisyphus.reasoningEffort).toBe("medium")
|
||||
expect(agents.sisyphus.thinking).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Sisyphus uses first fallbackChain entry when no availableModels provided", async () => {
|
||||
test("Sisyphus uses system default when no availableModels provided", async () => {
|
||||
// #given
|
||||
const systemDefaultModel = "openai/gpt-5.2"
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, systemDefaultModel)
|
||||
|
||||
// #then - Sisyphus first fallbackChain entry is anthropic/claude-opus-4-5
|
||||
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.Sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.Sisyphus.reasoningEffort).toBeUndefined()
|
||||
// #then - falls back to system default when no availability match
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Oracle uses first fallbackChain entry when no availableModels provided", async () => {
|
||||
// #given - Oracle's first fallbackChain entry is openai/gpt-5.2
|
||||
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
|
||||
// #given - no available models simulates CI without model cache
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - Oracle first fallbackChain entry is openai/gpt-5.2
|
||||
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.textVerbosity).toBe("high")
|
||||
@@ -90,19 +90,19 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.oracle.textVerbosity).toBeUndefined()
|
||||
})
|
||||
|
||||
test("non-model overrides are still applied after factory rebuild", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
Sisyphus: { model: "github-copilot/gpt-5.2", temperature: 0.5 },
|
||||
}
|
||||
test("non-model overrides are still applied after factory rebuild", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
sisyphus: { model: "github-copilot/gpt-5.2", temperature: 0.5 },
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
expect(agents.Sisyphus.temperature).toBe(0.5)
|
||||
})
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
expect(agents.sisyphus.temperature).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
|
||||
@@ -19,16 +19,16 @@ import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
Sisyphus: createSisyphusAgent,
|
||||
sisyphus: createSisyphusAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
"Metis (Plan Consultant)": createMetisAgent,
|
||||
"Momus (Plan Reviewer)": createMomusAgent,
|
||||
metis: createMetisAgent,
|
||||
momus: createMomusAgent,
|
||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
Atlas: createAtlasAgent as unknown as AgentFactory,
|
||||
atlas: createAtlasAgent as unknown as AgentFactory,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
}
|
||||
|
||||
export async function createBuiltinAgents(
|
||||
disabledAgents: BuiltinAgentName[] = [],
|
||||
disabledAgents: string[] = [],
|
||||
agentOverrides: AgentOverrides = {},
|
||||
directory?: string,
|
||||
systemDefaultModel?: string,
|
||||
@@ -186,18 +186,18 @@ export async function createBuiltinAgents(
|
||||
|
||||
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
|
||||
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (agentName === "Sisyphus") continue
|
||||
if (agentName === "Atlas") continue
|
||||
if (includesCaseInsensitive(disabledAgents, agentName)) continue
|
||||
if (agentName === "sisyphus") continue
|
||||
if (agentName === "atlas") continue
|
||||
if (includesCaseInsensitive(disabledAgents, agentName)) continue
|
||||
|
||||
const override = findCaseInsensitive(agentOverrides, agentName)
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model } = resolveModelWithFallback({
|
||||
const { model, variant: resolvedVariant } = resolveModelWithFallback({
|
||||
userModel: override?.model,
|
||||
fallbackChain: requirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -206,11 +206,11 @@ export async function createBuiltinAgents(
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig)
|
||||
|
||||
// Apply variant from override or requirement
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (override?.variant) {
|
||||
config = { ...config, variant: override.variant }
|
||||
} else if (requirement?.variant) {
|
||||
config = { ...config, variant: requirement.variant }
|
||||
} else if (resolvedVariant) {
|
||||
config = { ...config, variant: resolvedVariant }
|
||||
}
|
||||
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
@@ -234,12 +234,12 @@ export async function createBuiltinAgents(
|
||||
}
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("Sisyphus")) {
|
||||
const sisyphusOverride = agentOverrides["Sisyphus"]
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["Sisyphus"]
|
||||
if (!disabledAgents.includes("sisyphus")) {
|
||||
const sisyphusOverride = agentOverrides["sisyphus"]
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model: sisyphusModel } = resolveModelWithFallback({
|
||||
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = resolveModelWithFallback({
|
||||
userModel: sisyphusOverride?.model,
|
||||
fallbackChain: sisyphusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -254,11 +254,11 @@ export async function createBuiltinAgents(
|
||||
availableCategories
|
||||
)
|
||||
|
||||
// Apply variant from override or requirement
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (sisyphusOverride?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
|
||||
} else if (sisyphusRequirement?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusRequirement.variant }
|
||||
} else if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
@@ -270,15 +270,15 @@ export async function createBuiltinAgents(
|
||||
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
|
||||
}
|
||||
|
||||
result["Sisyphus"] = sisyphusConfig
|
||||
}
|
||||
result["sisyphus"] = sisyphusConfig
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("Atlas")) {
|
||||
const orchestratorOverride = agentOverrides["Atlas"]
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["Atlas"]
|
||||
if (!disabledAgents.includes("atlas")) {
|
||||
const orchestratorOverride = agentOverrides["atlas"]
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model: atlasModel } = resolveModelWithFallback({
|
||||
const { model: atlasModel, variant: atlasResolvedVariant } = resolveModelWithFallback({
|
||||
userModel: orchestratorOverride?.model,
|
||||
fallbackChain: atlasRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -292,19 +292,19 @@ export async function createBuiltinAgents(
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
// Apply variant from override or requirement
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (orchestratorOverride?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
|
||||
} else if (atlasRequirement?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasRequirement.variant }
|
||||
} else if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
if (orchestratorOverride) {
|
||||
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
|
||||
}
|
||||
|
||||
result["Atlas"] = orchestratorConfig
|
||||
}
|
||||
result["atlas"] = orchestratorConfig
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,7 +219,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #then should use native anthropic sonnet (cost-efficient for standard plan)
|
||||
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
|
||||
expect(result.agents).toBeDefined()
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-sonnet-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-sonnet-4-5")
|
||||
})
|
||||
|
||||
test("generates native opus models when Claude max20 subscription", () => {
|
||||
@@ -238,7 +238,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then should use native anthropic opus (max power for max20 plan)
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("uses github-copilot sonnet fallback when only copilot available", () => {
|
||||
@@ -257,7 +257,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then should use github-copilot sonnet models (copilot fallback)
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("github-copilot/claude-sonnet-4.5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-sonnet-4.5")
|
||||
})
|
||||
|
||||
test("uses ultimate fallback when no providers configured", () => {
|
||||
@@ -277,7 +277,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
// #then should use ultimate fallback for all agents
|
||||
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("opencode/glm-4.7-free")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/glm-4.7-free")
|
||||
})
|
||||
|
||||
test("uses zai-coding-plan/glm-4.7 for librarian when Z.ai available", () => {
|
||||
@@ -298,7 +298,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #then librarian should use zai-coding-plan/glm-4.7
|
||||
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
|
||||
// #then other agents should use native opus (max20 plan)
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("uses native OpenAI models when only ChatGPT available", () => {
|
||||
@@ -317,7 +317,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then Sisyphus should use native OpenAI (fallback within native tier)
|
||||
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("openai/gpt-5.2")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("openai/gpt-5.2")
|
||||
// #then Oracle should use native OpenAI (first fallback entry)
|
||||
expect((result.agents as Record<string, { model: string }>).oracle.model).toBe("openai/gpt-5.2")
|
||||
// #then multimodal-looker should use native OpenAI (fallback within native tier)
|
||||
@@ -343,7 +343,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("uses grok-code for explore when not max20", () => {
|
||||
test("uses haiku for explore regardless of max20 flag", () => {
|
||||
// #given user has Claude but not max20
|
||||
const config: InstallConfig = {
|
||||
hasClaude: true,
|
||||
@@ -358,7 +358,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then explore should use grok-code (preserve Claude quota)
|
||||
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("opencode/grok-code")
|
||||
// #then explore should use haiku (isMax20 doesn't affect explore anymore)
|
||||
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,10 +16,10 @@ describe("dependencies check", () => {
|
||||
})
|
||||
|
||||
describe("checkAstGrepNapi", () => {
|
||||
it("returns dependency info", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// #given
|
||||
// #when checking ast-grep napi
|
||||
const info = deps.checkAstGrepNapi()
|
||||
const info = await deps.checkAstGrepNapi()
|
||||
|
||||
// #then should return valid info
|
||||
expect(info.name).toBe("AST-Grep NAPI")
|
||||
@@ -95,7 +95,7 @@ describe("dependencies check", () => {
|
||||
|
||||
it("returns pass when installed", async () => {
|
||||
// #given napi installed
|
||||
checkSpy = spyOn(deps, "checkAstGrepNapi").mockReturnValue({
|
||||
checkSpy = spyOn(deps, "checkAstGrepNapi").mockResolvedValue({
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
installed: true,
|
||||
|
||||
@@ -56,9 +56,10 @@ export async function checkAstGrepCli(): Promise<DependencyInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
export function checkAstGrepNapi(): DependencyInfo {
|
||||
export async function checkAstGrepNapi(): Promise<DependencyInfo> {
|
||||
// Try dynamic import first (works in bunx temporary environments)
|
||||
try {
|
||||
require.resolve("@ast-grep/napi")
|
||||
await import("@ast-grep/napi")
|
||||
return {
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
@@ -67,6 +68,28 @@ export function checkAstGrepNapi(): DependencyInfo {
|
||||
path: null,
|
||||
}
|
||||
} catch {
|
||||
// Fallback: check common installation paths
|
||||
const { existsSync } = await import("fs")
|
||||
const { join } = await import("path")
|
||||
const { homedir } = await import("os")
|
||||
|
||||
const pathsToCheck = [
|
||||
join(homedir(), ".config", "opencode", "node_modules", "@ast-grep", "napi"),
|
||||
join(process.cwd(), "node_modules", "@ast-grep", "napi"),
|
||||
]
|
||||
|
||||
for (const napiPath of pathsToCheck) {
|
||||
if (existsSync(napiPath)) {
|
||||
return {
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: napiPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
@@ -127,7 +150,7 @@ export async function checkDependencyAstGrepCli(): Promise<CheckResult> {
|
||||
}
|
||||
|
||||
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
|
||||
const info = checkAstGrepNapi()
|
||||
const info = await checkAstGrepNapi()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("model-resolution check", () => {
|
||||
const info = getModelResolutionInfo()
|
||||
|
||||
// #then: Should have agent entries
|
||||
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-5")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
|
||||
@@ -84,7 +84,7 @@ describe("model-resolution check", () => {
|
||||
const info = getModelResolutionInfoWithOverrides(mockConfig)
|
||||
|
||||
// #then: Should show provider fallback chain
|
||||
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.userOverride).toBeUndefined()
|
||||
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
|
||||
@@ -97,13 +97,14 @@ describe("model-resolution check", () => {
|
||||
// #when: Running the model resolution check
|
||||
// #then: Returns pass with details showing resolution flow
|
||||
|
||||
it("returns pass status with agent and category counts", async () => {
|
||||
it("returns pass or warn status with agent and category counts", async () => {
|
||||
const { checkModelResolution } = await import("./model-resolution")
|
||||
|
||||
const result = await checkModelResolution()
|
||||
|
||||
// #then: Should pass and show counts
|
||||
expect(result.status).toBe("pass")
|
||||
// #then: Should pass (with cache) or warn (no cache) and show counts
|
||||
// In CI without model cache, status is "warn"; locally with cache, status is "pass"
|
||||
expect(["pass", "warn"]).toContain(result.status)
|
||||
expect(result.message).toMatch(/\d+ agents?, \d+ categories?/)
|
||||
})
|
||||
|
||||
@@ -115,8 +116,9 @@ describe("model-resolution check", () => {
|
||||
// #then: Details should contain agent/category resolution info
|
||||
expect(result.details).toBeDefined()
|
||||
expect(result.details!.length).toBeGreaterThan(0)
|
||||
// Should have Current Models header and sections
|
||||
expect(result.details!.some((d) => d.includes("Current Models"))).toBe(true)
|
||||
// Should have Available Models and Configured Models headers
|
||||
expect(result.details!.some((d) => d.includes("Available Models"))).toBe(true)
|
||||
expect(result.details!.some((d) => d.includes("Configured Models"))).toBe(true)
|
||||
expect(result.details!.some((d) => d.includes("Agents:"))).toBe(true)
|
||||
expect(result.details!.some((d) => d.includes("Categories:"))).toBe(true)
|
||||
// Should have legend
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { readFileSync, existsSync } from "node:fs"
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { parseJsonc, detectConfigFile } from "../../../shared"
|
||||
@@ -10,6 +10,38 @@ import {
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
function getOpenCodeCacheDir(): string {
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
if (xdgCache) return join(xdgCache, "opencode")
|
||||
return join(homedir(), ".cache", "opencode")
|
||||
}
|
||||
|
||||
function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } {
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
return { providers: [], modelCount: 0, cacheExists: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, { models?: Record<string, unknown> }>
|
||||
|
||||
const providers = Object.keys(data)
|
||||
let modelCount = 0
|
||||
for (const providerId of providers) {
|
||||
const models = data[providerId]?.models
|
||||
if (models && typeof models === "object") {
|
||||
modelCount += Object.keys(models).length
|
||||
}
|
||||
}
|
||||
|
||||
return { providers, modelCount, cacheExists: true }
|
||||
} catch {
|
||||
return { providers: [], modelCount: 0, cacheExists: false }
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
|
||||
@@ -155,10 +187,28 @@ function getEffectiveVariant(requirement: ModelRequirement): string | undefined
|
||||
return firstEntry?.variant ?? requirement.variant
|
||||
}
|
||||
|
||||
function buildDetailsArray(info: ModelResolutionInfo): string[] {
|
||||
interface AvailableModelsInfo {
|
||||
providers: string[]
|
||||
modelCount: number
|
||||
cacheExists: boolean
|
||||
}
|
||||
|
||||
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo): string[] {
|
||||
const details: string[] = []
|
||||
|
||||
details.push("═══ Current Models ═══")
|
||||
details.push("═══ Available Models (from cache) ═══")
|
||||
details.push("")
|
||||
if (available.cacheExists) {
|
||||
details.push(` Providers: ${available.providers.length} (${available.providers.slice(0, 8).join(", ")}${available.providers.length > 8 ? "..." : ""})`)
|
||||
details.push(` Total models: ${available.modelCount}`)
|
||||
details.push(` Cache: ~/.cache/opencode/models.json`)
|
||||
details.push(` Refresh: opencode models --refresh`)
|
||||
} else {
|
||||
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
|
||||
}
|
||||
details.push("")
|
||||
|
||||
details.push("═══ Configured Models ═══")
|
||||
details.push("")
|
||||
details.push("Agents:")
|
||||
for (const agent of info.agents) {
|
||||
@@ -182,6 +232,7 @@ function buildDetailsArray(info: ModelResolutionInfo): string[] {
|
||||
export async function checkModelResolution(): Promise<CheckResult> {
|
||||
const config = loadConfig() ?? {}
|
||||
const info = getModelResolutionInfoWithOverrides(config)
|
||||
const available = loadAvailableModels()
|
||||
|
||||
const agentCount = info.agents.length
|
||||
const categoryCount = info.categories.length
|
||||
@@ -190,12 +241,13 @@ export async function checkModelResolution(): Promise<CheckResult> {
|
||||
const totalOverrides = agentOverrides + categoryOverrides
|
||||
|
||||
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
|
||||
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found"
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
|
||||
status: "pass",
|
||||
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}`,
|
||||
details: buildDetailsArray(info),
|
||||
status: available.cacheExists ? "pass" : "warn",
|
||||
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
|
||||
details: buildDetailsArray(info, available),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean;
|
||||
const version = isPinned ? plugin.split("@")[1] : null
|
||||
return { entry: plugin, isPinned, version }
|
||||
}
|
||||
if (plugin.startsWith("file://") && plugin.includes(PACKAGE_NAME)) {
|
||||
return { entry: plugin, isPinned: false, version: "local-dev" }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(formatProvider("Gemini", config.hasGemini))
|
||||
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
|
||||
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
|
||||
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian: glm-4.7"))
|
||||
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
|
||||
|
||||
lines.push("")
|
||||
lines.push(color.dim("─".repeat(40)))
|
||||
@@ -250,7 +250,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
message: "Do you have a Z.ai Coding Plan subscription?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
|
||||
{ value: "yes" as const, label: "Yes", hint: "zai-coding-plan/glm-4.7 for Librarian" },
|
||||
{ value: "yes" as const, label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
|
||||
],
|
||||
initialValue: initial.zaiCodingPlan,
|
||||
})
|
||||
|
||||
@@ -310,19 +310,19 @@ describe("generateModelConfig", () => {
|
||||
})
|
||||
|
||||
describe("explore agent special cases", () => {
|
||||
test("explore uses Gemini flash when Gemini available", () => {
|
||||
// #given Gemini is available
|
||||
test("explore uses grok-code when only Gemini available (no Claude)", () => {
|
||||
// #given only Gemini is available (no Claude)
|
||||
const config = createConfig({ hasGemini: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should use gemini-3-flash-preview
|
||||
expect(result.agents?.explore?.model).toBe("google/gemini-3-flash-preview")
|
||||
// #then explore should use grok-code (Claude haiku not available)
|
||||
expect(result.agents?.explore?.model).toBe("opencode/grok-code")
|
||||
})
|
||||
|
||||
test("explore uses Claude haiku when Claude + isMax20 but no Gemini", () => {
|
||||
// #given Claude is available with Max 20 plan but no Gemini
|
||||
test("explore uses Claude haiku when Claude available", () => {
|
||||
// #given Claude is available
|
||||
const config = createConfig({ hasClaude: true, isMax20: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
@@ -332,15 +332,15 @@ describe("generateModelConfig", () => {
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("explore uses grok-code when Claude without isMax20 and no Gemini", () => {
|
||||
// #given Claude is available without Max 20 plan and no Gemini
|
||||
test("explore uses Claude haiku regardless of isMax20 flag", () => {
|
||||
// #given Claude is available without Max 20 plan
|
||||
const config = createConfig({ hasClaude: true, isMax20: false })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should use grok-code
|
||||
expect(result.agents?.explore?.model).toBe("opencode/grok-code")
|
||||
// #then explore should use claude-haiku-4-5 (isMax20 doesn't affect explore)
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("explore uses grok-code when only OpenAI available", () => {
|
||||
@@ -364,7 +364,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then Sisyphus should use opus (sisyphus-high)
|
||||
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("Sisyphus uses sisyphus-low capability when isMax20 is false", () => {
|
||||
@@ -375,7 +375,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then Sisyphus should use sonnet (sisyphus-low)
|
||||
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-sonnet-4-5")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-sonnet-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ function resolveModelFromChain(
|
||||
function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] {
|
||||
// Sisyphus uses opus when isMaxPlan, sonnet otherwise
|
||||
if (isMaxPlan) {
|
||||
return AGENT_MODEL_REQUIREMENTS.Sisyphus.fallbackChain
|
||||
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
|
||||
}
|
||||
// For non-max plan, use sonnet instead of opus
|
||||
return [
|
||||
@@ -139,12 +139,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
continue
|
||||
}
|
||||
|
||||
// Special case: explore has custom Gemini → Claude → Grok logic
|
||||
// Special case: explore uses Claude haiku → OpenCode grok-code
|
||||
if (role === "explore") {
|
||||
if (avail.native.gemini) {
|
||||
agents[role] = { model: "google/gemini-3-flash-preview" }
|
||||
} else if (avail.native.claude && avail.isMaxPlan) {
|
||||
if (avail.native.claude) {
|
||||
agents[role] = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeZen) {
|
||||
agents[role] = { model: "opencode/claude-haiku-4-5" }
|
||||
} else {
|
||||
agents[role] = { model: "opencode/grok-code" }
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
|
||||
// Special case: Sisyphus uses different fallbackChain based on isMaxPlan
|
||||
const fallbackChain =
|
||||
role === "Sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain
|
||||
role === "sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain
|
||||
|
||||
const resolved = resolveModelFromChain(fallbackChain, avail)
|
||||
if (resolved) {
|
||||
|
||||
@@ -375,7 +375,7 @@ describe("Sisyphus-Junior agent override", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
"Sisyphus-Junior": {
|
||||
"sisyphus-junior": {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.2,
|
||||
},
|
||||
@@ -388,18 +388,18 @@ describe("Sisyphus-Junior agent override", () => {
|
||||
// #then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.["Sisyphus-Junior"]).toBeDefined()
|
||||
expect(result.data.agents?.["Sisyphus-Junior"]?.model).toBe("openai/gpt-5.2")
|
||||
expect(result.data.agents?.["Sisyphus-Junior"]?.temperature).toBe(0.2)
|
||||
expect(result.data.agents?.["sisyphus-junior"]).toBeDefined()
|
||||
expect(result.data.agents?.["sisyphus-junior"]?.model).toBe("openai/gpt-5.2")
|
||||
expect(result.data.agents?.["sisyphus-junior"]?.temperature).toBe(0.2)
|
||||
}
|
||||
})
|
||||
|
||||
test("schema accepts Sisyphus-Junior with prompt_append", () => {
|
||||
test("schema accepts sisyphus-junior with prompt_append", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
"Sisyphus-Junior": {
|
||||
prompt_append: "Additional instructions for Sisyphus-Junior",
|
||||
"sisyphus-junior": {
|
||||
prompt_append: "Additional instructions for sisyphus-junior",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -410,17 +410,17 @@ describe("Sisyphus-Junior agent override", () => {
|
||||
// #then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.["Sisyphus-Junior"]?.prompt_append).toBe(
|
||||
"Additional instructions for Sisyphus-Junior"
|
||||
expect(result.data.agents?.["sisyphus-junior"]?.prompt_append).toBe(
|
||||
"Additional instructions for sisyphus-junior"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("schema accepts Sisyphus-Junior with tools override", () => {
|
||||
test("schema accepts sisyphus-junior with tools override", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
"Sisyphus-Junior": {
|
||||
"sisyphus-junior": {
|
||||
tools: {
|
||||
read: true,
|
||||
write: false,
|
||||
@@ -435,10 +435,62 @@ describe("Sisyphus-Junior agent override", () => {
|
||||
// #then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.["Sisyphus-Junior"]?.tools).toEqual({
|
||||
expect(result.data.agents?.["sisyphus-junior"]?.tools).toEqual({
|
||||
read: true,
|
||||
write: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("schema accepts lowercase agent names (sisyphus, atlas, prometheus)", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
atlas: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
prometheus: {
|
||||
temperature: 0.3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// #then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.sisyphus?.temperature).toBe(0.1)
|
||||
expect(result.data.agents?.atlas?.temperature).toBe(0.2)
|
||||
expect(result.data.agents?.prometheus?.temperature).toBe(0.3)
|
||||
}
|
||||
})
|
||||
|
||||
test("schema accepts lowercase metis and momus agent names", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
metis: {
|
||||
category: "ultrabrain",
|
||||
},
|
||||
momus: {
|
||||
category: "quick",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// #then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.metis?.category).toBe("ultrabrain")
|
||||
expect(result.data.agents?.momus?.category).toBe("quick")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,14 +17,15 @@ const AgentPermissionSchema = z.object({
|
||||
})
|
||||
|
||||
export const BuiltinAgentNameSchema = z.enum([
|
||||
"Sisyphus",
|
||||
"sisyphus",
|
||||
"prometheus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"Atlas",
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
@@ -36,17 +37,17 @@ export const BuiltinSkillNameSchema = z.enum([
|
||||
export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"plan",
|
||||
"Sisyphus",
|
||||
"Sisyphus-Junior",
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"OpenCode-Builder",
|
||||
"Prometheus (Planner)",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"prometheus",
|
||||
"metis",
|
||||
"momus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"Atlas",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
export const AgentNameSchema = BuiltinAgentNameSchema
|
||||
@@ -117,17 +118,17 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
export const AgentOverridesSchema = z.object({
|
||||
build: AgentOverrideConfigSchema.optional(),
|
||||
plan: AgentOverrideConfigSchema.optional(),
|
||||
Sisyphus: AgentOverrideConfigSchema.optional(),
|
||||
"Sisyphus-Junior": AgentOverrideConfigSchema.optional(),
|
||||
sisyphus: AgentOverrideConfigSchema.optional(),
|
||||
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
|
||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||
"Prometheus (Planner)": AgentOverrideConfigSchema.optional(),
|
||||
"Metis (Plan Consultant)": AgentOverrideConfigSchema.optional(),
|
||||
"Momus (Plan Reviewer)": AgentOverrideConfigSchema.optional(),
|
||||
prometheus: AgentOverrideConfigSchema.optional(),
|
||||
metis: AgentOverrideConfigSchema.optional(),
|
||||
momus: AgentOverrideConfigSchema.optional(),
|
||||
oracle: AgentOverrideConfigSchema.optional(),
|
||||
librarian: AgentOverrideConfigSchema.optional(),
|
||||
explore: AgentOverrideConfigSchema.optional(),
|
||||
"multimodal-looker": AgentOverrideConfigSchema.optional(),
|
||||
Atlas: AgentOverrideConfigSchema.optional(),
|
||||
atlas: AgentOverrideConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export const ClaudeCodeConfigSchema = z.object({
|
||||
|
||||
@@ -123,4 +123,40 @@ describe("claude-code-session-state", () => {
|
||||
expect(getSessionAgent(sessionID)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("issue #893: custom agent switch reset", () => {
|
||||
test("should preserve custom agent when default agent is sent on subsequent messages", () => {
|
||||
// #given - user switches to custom agent "MyCustomAgent"
|
||||
const sessionID = "test-session-custom"
|
||||
const customAgent = "MyCustomAgent"
|
||||
const defaultAgent = "Sisyphus"
|
||||
|
||||
// User switches to custom agent (via UI)
|
||||
setSessionAgent(sessionID, customAgent)
|
||||
expect(getSessionAgent(sessionID)).toBe(customAgent)
|
||||
|
||||
// #when - first message after switch sends default agent
|
||||
// This simulates the bug: input.agent = "Sisyphus" on first message
|
||||
// Using setSessionAgent (first-write wins) should preserve custom agent
|
||||
setSessionAgent(sessionID, defaultAgent)
|
||||
|
||||
// #then - custom agent should be preserved, NOT overwritten
|
||||
expect(getSessionAgent(sessionID)).toBe(customAgent)
|
||||
})
|
||||
|
||||
test("should allow explicit agent update via updateSessionAgent", () => {
|
||||
// #given - custom agent is set
|
||||
const sessionID = "test-session-explicit"
|
||||
const customAgent = "MyCustomAgent"
|
||||
const newAgent = "AnotherAgent"
|
||||
|
||||
setSessionAgent(sessionID, customAgent)
|
||||
|
||||
// #when - explicit update (user intentionally switches)
|
||||
updateSessionAgent(sessionID, newAgent)
|
||||
|
||||
// #then - should be updated
|
||||
expect(getSessionAgent(sessionID)).toBe(newAgent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── atlas/ # Main orchestration (771 lines)
|
||||
├── atlas/ # Main orchestration (773 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
|
||||
@@ -274,6 +274,7 @@ function getGitDiffStats(directory: string): GitFileStat[] {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
if (!output) return []
|
||||
@@ -282,6 +283,7 @@ function getGitDiffStats(directory: string): GitFileStat[] {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const statusMap = new Map<string, "modified" | "added" | "deleted">()
|
||||
@@ -397,7 +399,7 @@ function isCallerOrchestrator(sessionID?: string): boolean {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return false
|
||||
const nearest = findNearestMessageWithFields(messageDir)
|
||||
return nearest?.agent === "Atlas"
|
||||
return nearest?.agent?.toLowerCase() === "atlas"
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
|
||||
import { runBunInstall } from "../../cli/config-manager"
|
||||
import { isModelCacheAvailable } from "../../shared/model-availability"
|
||||
import type { AutoUpdateCheckerOptions } from "./types"
|
||||
|
||||
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
|
||||
@@ -75,6 +76,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
|
||||
const displayVersion = localDevVersion ?? cachedVersion
|
||||
|
||||
await showConfigErrorsIfAny(ctx)
|
||||
await showModelCacheWarningIfNeeded(ctx)
|
||||
|
||||
if (localDevVersion) {
|
||||
if (showStartupToast) {
|
||||
@@ -167,6 +169,23 @@ async function runBunInstallSafe(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise<void> {
|
||||
if (isModelCacheAvailable()) return
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Model Cache Not Found",
|
||||
message: "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.",
|
||||
variant: "warning" as const,
|
||||
duration: 10000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
log("[auto-update-checker] Model cache warning shown")
|
||||
}
|
||||
|
||||
async function showConfigErrorsIfAny(ctx: PluginInput): Promise<void> {
|
||||
const errors = getConfigLoadErrors()
|
||||
if (errors.length === 0) return
|
||||
|
||||
@@ -178,7 +178,11 @@ describe("non-interactive-env hook", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-platform shell support", () => {
|
||||
describe("bash tool always uses unix shell syntax", () => {
|
||||
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
|
||||
// (via Git Bash, WSL, etc.), so we should always use unix export syntax.
|
||||
// This fixes GitHub issues #983 and #889.
|
||||
|
||||
test("#given macOS platform #when git command executes #then uses unix export syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
@@ -221,7 +225,9 @@ describe("non-interactive-env hook", () => {
|
||||
expect(cmd).toContain("; git commit")
|
||||
})
|
||||
|
||||
test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => {
|
||||
test("#given Windows with PowerShell env #when bash tool git command executes #then still uses unix export syntax", async () => {
|
||||
// Even when PSModulePath is set (indicating PowerShell environment),
|
||||
// the bash tool runs in a Unix-like shell, so we use export syntax
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
@@ -236,13 +242,16 @@ describe("non-interactive-env hook", () => {
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
// Should use unix export syntax, NOT PowerShell $env: syntax
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git status")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
expect(cmd).not.toContain("set ")
|
||||
})
|
||||
|
||||
test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => {
|
||||
test("#given Windows without SHELL env #when bash tool git command executes #then still uses unix export syntax", async () => {
|
||||
// Even when detectShellType() would return "cmd" (no SHELL, no PSModulePath, win32),
|
||||
// the bash tool runs in a Unix-like shell, so we use export syntax
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
@@ -258,14 +267,18 @@ describe("non-interactive-env hook", () => {
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("set ")
|
||||
expect(cmd).toContain("&&")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
// Should use unix export syntax, NOT cmd.exe set syntax
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git log")
|
||||
expect(cmd).not.toContain("set ")
|
||||
expect(cmd).not.toContain("&&")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
})
|
||||
|
||||
test("#given PowerShell #when values contain quotes #then escapes correctly", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
test("#given Windows Git Bash environment #when git command executes #then uses unix export syntax", async () => {
|
||||
// Simulating Git Bash on Windows: SHELL might be set to /usr/bin/bash
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/usr/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
@@ -279,32 +292,16 @@ describe("non-interactive-env hook", () => {
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/\$env:\w+='[^']*'/)
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git status")
|
||||
})
|
||||
|
||||
test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => {
|
||||
test("#given any platform #when chained git commands via bash tool #then uses unix export syntax", async () => {
|
||||
// Even on Windows, chained commands should use unix syntax
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/set \w+="[^"]*"/)
|
||||
})
|
||||
|
||||
test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git commit -m 'test'" },
|
||||
@@ -316,7 +313,7 @@ describe("non-interactive-env hook", () => {
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git add file && git commit")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ShellType } from "../../shared"
|
||||
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
|
||||
import { isNonInteractive } from "./detector"
|
||||
import { log, detectShellType, buildEnvPrefix } from "../../shared"
|
||||
import { log, buildEnvPrefix } from "../../shared"
|
||||
|
||||
export * from "./constants"
|
||||
export * from "./detector"
|
||||
@@ -50,7 +51,10 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const shellType = detectShellType()
|
||||
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
|
||||
// (via Git Bash, WSL, etc.), so we always use unix export syntax.
|
||||
// This fixes GitHub issues #983 and #889.
|
||||
const shellType: ShellType = "unix"
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
|
||||
export const HOOK_NAME = "prometheus-md-only"
|
||||
|
||||
export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"]
|
||||
export const PROMETHEUS_AGENTS = ["prometheus"]
|
||||
|
||||
export const ALLOWED_EXTENSIONS = [".md"]
|
||||
|
||||
@@ -16,7 +17,7 @@ export const PLANNING_CONSULT_WARNING = `
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)}
|
||||
|
||||
You are being invoked by Prometheus (Planner), a READ-ONLY planning agent.
|
||||
You are being invoked by ${getAgentDisplayName("prometheus")}, a READ-ONLY planning agent.
|
||||
|
||||
**CRITICAL CONSTRAINTS:**
|
||||
- DO NOT modify any files (no Write, Edit, or any file mutations)
|
||||
|
||||
@@ -41,10 +41,10 @@ describe("prometheus-md-only", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("with Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
})
|
||||
describe("with Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
})
|
||||
|
||||
test("should block Prometheus from writing non-.md files", async () => {
|
||||
// #given
|
||||
@@ -345,185 +345,195 @@ describe("prometheus-md-only", () => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
})
|
||||
|
||||
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
|
||||
}
|
||||
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow mixed separator paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
|
||||
}
|
||||
test("should allow mixed separator paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow uppercase .MD extension", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/plans/work-plan.MD" },
|
||||
}
|
||||
test("should allow uppercase .MD extension", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block paths outside workspace root even if containing .sisyphus", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
|
||||
}
|
||||
test("should block paths outside workspace root even if containing .sisyphus", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
|
||||
// #given - when ctx.directory is parent of actual project, path includes project name
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "src/.sisyphus/plans/x.md" },
|
||||
}
|
||||
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
|
||||
// #given - when ctx.directory is parent of actual project, path includes project name
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "src/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then - should allow because .sisyphus is in path
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then - should allow because .sisyphus is in path
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block path traversal attempts", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/../secrets.md" },
|
||||
}
|
||||
test("should block path traversal attempts", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/../secrets.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should allow case-insensitive .SISYPHUS directory", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
|
||||
}
|
||||
test("should allow case-insensitive .SISYPHUS directory", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
|
||||
// #given - simulates when ctx.directory is parent of actual project
|
||||
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
|
||||
}
|
||||
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
|
||||
// #given - simulates when ctx.directory is parent of actual project
|
||||
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow nested project path with mixed separators", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
|
||||
}
|
||||
test("should allow nested project path with mixed separators", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block nested project path without .sisyphus", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project\\src\\code.ts" },
|
||||
}
|
||||
test("should block nested project path without .sisyphus", async () => {
|
||||
// #given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project\\src\\code.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAG
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
|
||||
export * from "./constants"
|
||||
|
||||
@@ -110,20 +111,20 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] Prometheus (Planner) can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`Prometheus is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
|
||||
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-di
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
const DEFAULT_SKIP_AGENTS = ["Prometheus (Planner)"]
|
||||
const DEFAULT_SKIP_AGENTS = ["prometheus"]
|
||||
|
||||
export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
|
||||
81
src/index.test.ts
Normal file
81
src/index.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { includesCaseInsensitive } from "./shared"
|
||||
|
||||
/**
|
||||
* Tests for conditional tool registration logic in index.ts
|
||||
*
|
||||
* The actual plugin initialization is complex to test directly,
|
||||
* so we test the underlying logic that determines tool registration.
|
||||
*/
|
||||
describe("look_at tool conditional registration", () => {
|
||||
describe("isMultimodalLookerEnabled logic", () => {
|
||||
// #given multimodal-looker is in disabled_agents
|
||||
// #when checking if agent is enabled
|
||||
// #then should return false (disabled)
|
||||
it("returns false when multimodal-looker is disabled (exact case)", () => {
|
||||
const disabledAgents = ["multimodal-looker"]
|
||||
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
|
||||
expect(isEnabled).toBe(false)
|
||||
})
|
||||
|
||||
// #given multimodal-looker is in disabled_agents with different case
|
||||
// #when checking if agent is enabled
|
||||
// #then should return false (case-insensitive match)
|
||||
it("returns false when multimodal-looker is disabled (case-insensitive)", () => {
|
||||
const disabledAgents = ["Multimodal-Looker"]
|
||||
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
|
||||
expect(isEnabled).toBe(false)
|
||||
})
|
||||
|
||||
// #given multimodal-looker is NOT in disabled_agents
|
||||
// #when checking if agent is enabled
|
||||
// #then should return true (enabled)
|
||||
it("returns true when multimodal-looker is not disabled", () => {
|
||||
const disabledAgents = ["oracle", "librarian"]
|
||||
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
|
||||
expect(isEnabled).toBe(true)
|
||||
})
|
||||
|
||||
// #given disabled_agents is empty
|
||||
// #when checking if agent is enabled
|
||||
// #then should return true (enabled by default)
|
||||
it("returns true when disabled_agents is empty", () => {
|
||||
const disabledAgents: string[] = []
|
||||
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
|
||||
expect(isEnabled).toBe(true)
|
||||
})
|
||||
|
||||
// #given disabled_agents is undefined (simulated as empty array)
|
||||
// #when checking if agent is enabled
|
||||
// #then should return true (enabled by default)
|
||||
it("returns true when disabled_agents is undefined (fallback to empty)", () => {
|
||||
const disabledAgents = undefined
|
||||
const isEnabled = !includesCaseInsensitive(disabledAgents ?? [], "multimodal-looker")
|
||||
expect(isEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("conditional tool spread pattern", () => {
|
||||
// #given lookAt is not null (agent enabled)
|
||||
// #when spreading into tool object
|
||||
// #then look_at should be included
|
||||
it("includes look_at when lookAt is not null", () => {
|
||||
const lookAt = { execute: () => {} } // mock tool
|
||||
const tools = {
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
}
|
||||
expect(tools).toHaveProperty("look_at")
|
||||
})
|
||||
|
||||
// #given lookAt is null (agent disabled)
|
||||
// #when spreading into tool object
|
||||
// #then look_at should NOT be included
|
||||
it("excludes look_at when lookAt is null", () => {
|
||||
const lookAt = null
|
||||
const tools = {
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
}
|
||||
expect(tools).not.toHaveProperty("look_at")
|
||||
})
|
||||
})
|
||||
})
|
||||
21
src/index.ts
21
src/index.ts
@@ -79,6 +79,7 @@ import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory })
|
||||
// Start background tmux check immediately
|
||||
startTmuxCheck();
|
||||
|
||||
@@ -198,10 +199,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createStartWorkHook(ctx)
|
||||
: null;
|
||||
|
||||
const atlasHook = isHookEnabled("atlas")
|
||||
? createAtlasHook(ctx)
|
||||
: null;
|
||||
|
||||
const prometheusMdOnly = isHookEnabled("prometheus-md-only")
|
||||
? createPrometheusMdOnlyHook(ctx)
|
||||
: null;
|
||||
@@ -210,6 +207,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const atlasHook = isHookEnabled("atlas")
|
||||
? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager })
|
||||
: null;
|
||||
|
||||
initTaskToastManager(ctx.client);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
@@ -229,13 +230,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
|
||||
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
const lookAt = createLookAt(ctx);
|
||||
const isMultimodalLookerEnabled = !includesCaseInsensitive(
|
||||
pluginConfig.disabled_agents ?? [],
|
||||
"multimodal-looker"
|
||||
);
|
||||
const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null;
|
||||
const delegateTask = createDelegateTask({
|
||||
manager: backgroundManager,
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
userCategories: pluginConfig.categories,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
|
||||
});
|
||||
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const systemMcpNames = getSystemMcpServerNames();
|
||||
@@ -298,7 +304,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
...builtinTools,
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
look_at: lookAt,
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
delegate_task: delegateTask,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
@@ -308,7 +314,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
if (input.agent) {
|
||||
updateSessionAgent(input.sessionID, input.agent);
|
||||
setSessionAgent(input.sessionID, input.agent);
|
||||
}
|
||||
|
||||
const message = (output as { message: { variant?: string } }).message
|
||||
@@ -485,6 +491,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await directoryReadmeInjector?.["tool.execute.before"]?.(input, output);
|
||||
await rulesInjector?.["tool.execute.before"]?.(input, output);
|
||||
await prometheusMdOnly?.["tool.execute.before"]?.(input, output);
|
||||
await atlasHook?.["tool.execute.before"]?.(input, output);
|
||||
|
||||
if (input.tool === "task") {
|
||||
const args = output.args as Record<string, unknown>;
|
||||
|
||||
@@ -186,20 +186,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
explore?: { tools?: Record<string, unknown> };
|
||||
librarian?: { tools?: Record<string, unknown> };
|
||||
"multimodal-looker"?: { tools?: Record<string, unknown> };
|
||||
Atlas?: { tools?: Record<string, unknown> };
|
||||
Sisyphus?: { tools?: Record<string, unknown> };
|
||||
atlas?: { tools?: Record<string, unknown> };
|
||||
sisyphus?: { tools?: Record<string, unknown> };
|
||||
};
|
||||
const configAgent = config.agent as AgentConfig | undefined;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
if (isSisyphusEnabled && builtinAgents.sisyphus) {
|
||||
(config as { default_agent?: string }).default_agent = "sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
sisyphus: builtinAgents.sisyphus,
|
||||
};
|
||||
|
||||
agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["Sisyphus-Junior"],
|
||||
agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["sisyphus-junior"],
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
@@ -228,7 +228,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
planConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const prometheusOverride =
|
||||
pluginConfig.agents?.["Prometheus (Planner)"] as
|
||||
pluginConfig.agents?.["prometheus"] as
|
||||
| (Record<string, unknown> & { category?: string; model?: string })
|
||||
| undefined;
|
||||
const defaultModel = config.model as string | undefined;
|
||||
@@ -275,7 +275,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {}),
|
||||
};
|
||||
|
||||
agentConfig["Prometheus (Planner)"] = prometheusOverride
|
||||
agentConfig["prometheus"] = prometheusOverride
|
||||
? { ...prometheusBase, ...prometheusOverride }
|
||||
: prometheusBase;
|
||||
}
|
||||
@@ -310,7 +310,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(
|
||||
Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")
|
||||
Object.entries(builtinAgents).filter(([k]) => k !== "sisyphus")
|
||||
),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
@@ -349,20 +349,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const agent = agentResult["multimodal-looker"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, task: "deny", look_at: "deny" };
|
||||
}
|
||||
if (agentResult["Atlas"]) {
|
||||
const agent = agentResult["Atlas"] as AgentWithPermission;
|
||||
if (agentResult["atlas"]) {
|
||||
const agent = agentResult["atlas"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow" };
|
||||
}
|
||||
if (agentResult.Sisyphus) {
|
||||
const agent = agentResult.Sisyphus as AgentWithPermission;
|
||||
if (agentResult.sisyphus) {
|
||||
const agent = agentResult.sisyphus as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
|
||||
}
|
||||
if (agentResult["Prometheus (Planner)"]) {
|
||||
const agent = agentResult["Prometheus (Planner)"] as AgentWithPermission;
|
||||
if (agentResult["prometheus"]) {
|
||||
const agent = agentResult["prometheus"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
|
||||
}
|
||||
if (agentResult["Sisyphus-Junior"]) {
|
||||
const agent = agentResult["Sisyphus-Junior"] as AgentWithPermission;
|
||||
if (agentResult["sisyphus-junior"]) {
|
||||
const agent = agentResult["sisyphus-junior"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, delegate_task: "allow" };
|
||||
}
|
||||
|
||||
|
||||
224
src/shared/agent-config-integration.test.ts
Normal file
224
src/shared/agent-config-integration.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { migrateAgentNames } from "./migration"
|
||||
import { getAgentDisplayName } from "./agent-display-names"
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
|
||||
describe("Agent Config Integration", () => {
|
||||
describe("Old format config migration", () => {
|
||||
test("migrates old format agent keys to lowercase", () => {
|
||||
// #given - config with old format keys
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
"Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-5" },
|
||||
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
const result = migrateAgentNames(oldConfig)
|
||||
|
||||
// #then - keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("atlas")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
expect(result.migrated).toHaveProperty("metis")
|
||||
expect(result.migrated).toHaveProperty("momus")
|
||||
|
||||
// #then - old keys are removed
|
||||
expect(result.migrated).not.toHaveProperty("Sisyphus")
|
||||
expect(result.migrated).not.toHaveProperty("Atlas")
|
||||
expect(result.migrated).not.toHaveProperty("Prometheus (Planner)")
|
||||
expect(result.migrated).not.toHaveProperty("Metis (Plan Consultant)")
|
||||
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)")
|
||||
|
||||
// #then - values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
|
||||
// #then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves already lowercase keys", () => {
|
||||
// #given - config with lowercase keys
|
||||
const config = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
const result = migrateAgentNames(config)
|
||||
|
||||
// #then - keys remain unchanged
|
||||
expect(result.migrated).toEqual(config)
|
||||
|
||||
// #then - changed flag is false
|
||||
expect(result.changed).toBe(false)
|
||||
})
|
||||
|
||||
test("handles mixed case config", () => {
|
||||
// #given - config with mixed old and new format
|
||||
const mixedConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
const result = migrateAgentNames(mixedConfig)
|
||||
|
||||
// #then - all keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("oracle")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
expect(result.migrated).toHaveProperty("librarian")
|
||||
expect(Object.keys(result.migrated).every((key) => key === key.toLowerCase())).toBe(true)
|
||||
|
||||
// #then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Display name resolution", () => {
|
||||
test("returns correct display names for all builtin agents", () => {
|
||||
// #given - lowercase config keys
|
||||
const agents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
|
||||
|
||||
// #when - display names are requested
|
||||
const displayNames = agents.map((agent) => getAgentDisplayName(agent))
|
||||
|
||||
// #then - display names are correct
|
||||
expect(displayNames).toContain("Sisyphus (Ultraworker)")
|
||||
expect(displayNames).toContain("Atlas (Plan Execution Orchestrator)")
|
||||
expect(displayNames).toContain("Prometheus (Plan Builder)")
|
||||
expect(displayNames).toContain("Metis (Plan Consultant)")
|
||||
expect(displayNames).toContain("Momus (Plan Reviewer)")
|
||||
expect(displayNames).toContain("oracle")
|
||||
expect(displayNames).toContain("librarian")
|
||||
expect(displayNames).toContain("explore")
|
||||
expect(displayNames).toContain("multimodal-looker")
|
||||
})
|
||||
|
||||
test("handles lowercase keys case-insensitively", () => {
|
||||
// #given - various case formats of lowercase keys
|
||||
const keys = ["Sisyphus", "Atlas", "SISYPHUS", "atlas", "prometheus", "PROMETHEUS"]
|
||||
|
||||
// #when - display names are requested
|
||||
const displayNames = keys.map((key) => getAgentDisplayName(key))
|
||||
|
||||
// #then - correct display names are returned
|
||||
expect(displayNames[0]).toBe("Sisyphus (Ultraworker)")
|
||||
expect(displayNames[1]).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
expect(displayNames[2]).toBe("Sisyphus (Ultraworker)")
|
||||
expect(displayNames[3]).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
expect(displayNames[4]).toBe("Prometheus (Plan Builder)")
|
||||
expect(displayNames[5]).toBe("Prometheus (Plan Builder)")
|
||||
})
|
||||
|
||||
test("returns original key for unknown agents", () => {
|
||||
// #given - unknown agent key
|
||||
const unknownKey = "custom-agent"
|
||||
|
||||
// #when - display name is requested
|
||||
const displayName = getAgentDisplayName(unknownKey)
|
||||
|
||||
// #then - original key is returned
|
||||
expect(displayName).toBe(unknownKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model requirements integration", () => {
|
||||
test("all model requirements use lowercase keys", () => {
|
||||
// #given - AGENT_MODEL_REQUIREMENTS object
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #when - checking key format
|
||||
const allLowercase = agentKeys.every((key) => key === key.toLowerCase())
|
||||
|
||||
// #then - all keys are lowercase
|
||||
expect(allLowercase).toBe(true)
|
||||
})
|
||||
|
||||
test("model requirements include all builtin agents", () => {
|
||||
// #given - expected builtin agents
|
||||
const expectedAgents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
|
||||
|
||||
// #when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all expected agents are present
|
||||
for (const agent of expectedAgents) {
|
||||
expect(agentKeys).toContain(agent)
|
||||
}
|
||||
})
|
||||
|
||||
test("no uppercase keys in model requirements", () => {
|
||||
// #given - AGENT_MODEL_REQUIREMENTS object
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #when - checking for uppercase keys
|
||||
const uppercaseKeys = agentKeys.filter((key) => key !== key.toLowerCase())
|
||||
|
||||
// #then - no uppercase keys exist
|
||||
expect(uppercaseKeys).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("End-to-end config flow", () => {
|
||||
test("old config migrates and displays correctly", () => {
|
||||
// #given - old format config
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5", temperature: 0.1 },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when - config is migrated
|
||||
const result = migrateAgentNames(oldConfig)
|
||||
|
||||
// #then - keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
|
||||
// #when - display names are retrieved
|
||||
const sisyphusDisplay = getAgentDisplayName("sisyphus")
|
||||
const prometheusDisplay = getAgentDisplayName("prometheus")
|
||||
|
||||
// #then - display names are correct
|
||||
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
|
||||
expect(prometheusDisplay).toBe("Prometheus (Plan Builder)")
|
||||
|
||||
// #then - config values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("new config works without migration", () => {
|
||||
// #given - new format config (already lowercase)
|
||||
const newConfig = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when - migration is applied (should be no-op)
|
||||
const result = migrateAgentNames(newConfig)
|
||||
|
||||
// #then - config is unchanged
|
||||
expect(result.migrated).toEqual(newConfig)
|
||||
|
||||
// #then - changed flag is false
|
||||
expect(result.changed).toBe(false)
|
||||
|
||||
// #when - display names are retrieved
|
||||
const sisyphusDisplay = getAgentDisplayName("sisyphus")
|
||||
const atlasDisplay = getAgentDisplayName("atlas")
|
||||
|
||||
// #then - display names are correct
|
||||
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
|
||||
expect(atlasDisplay).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
})
|
||||
})
|
||||
})
|
||||
158
src/shared/agent-display-names.test.ts
Normal file
158
src/shared/agent-display-names.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { AGENT_DISPLAY_NAMES, getAgentDisplayName } from "./agent-display-names"
|
||||
|
||||
describe("getAgentDisplayName", () => {
|
||||
it("returns display name for lowercase config key (new format)", () => {
|
||||
// #given config key "sisyphus"
|
||||
const configKey = "sisyphus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus (Ultraworker)"
|
||||
expect(result).toBe("Sisyphus (Ultraworker)")
|
||||
})
|
||||
|
||||
it("returns display name for uppercase config key (old format - case-insensitive)", () => {
|
||||
// #given config key "Sisyphus" (old format)
|
||||
const configKey = "Sisyphus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus (Ultraworker)" (case-insensitive lookup)
|
||||
expect(result).toBe("Sisyphus (Ultraworker)")
|
||||
})
|
||||
|
||||
it("returns original key for unknown agents (fallback)", () => {
|
||||
// #given config key "custom-agent"
|
||||
const configKey = "custom-agent"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "custom-agent" (original key unchanged)
|
||||
expect(result).toBe("custom-agent")
|
||||
})
|
||||
|
||||
it("returns display name for atlas", () => {
|
||||
// #given config key "atlas"
|
||||
const configKey = "atlas"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Atlas (Plan Execution Orchestrator)"
|
||||
expect(result).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
})
|
||||
|
||||
it("returns display name for prometheus", () => {
|
||||
// #given config key "prometheus"
|
||||
const configKey = "prometheus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Prometheus (Plan Builder)"
|
||||
expect(result).toBe("Prometheus (Plan Builder)")
|
||||
})
|
||||
|
||||
it("returns display name for sisyphus-junior", () => {
|
||||
// #given config key "sisyphus-junior"
|
||||
const configKey = "sisyphus-junior"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus-Junior"
|
||||
expect(result).toBe("Sisyphus-Junior")
|
||||
})
|
||||
|
||||
it("returns display name for metis", () => {
|
||||
// #given config key "metis"
|
||||
const configKey = "metis"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Metis (Plan Consultant)"
|
||||
expect(result).toBe("Metis (Plan Consultant)")
|
||||
})
|
||||
|
||||
it("returns display name for momus", () => {
|
||||
// #given config key "momus"
|
||||
const configKey = "momus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Momus (Plan Reviewer)"
|
||||
expect(result).toBe("Momus (Plan Reviewer)")
|
||||
})
|
||||
|
||||
it("returns display name for oracle", () => {
|
||||
// #given config key "oracle"
|
||||
const configKey = "oracle"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "oracle"
|
||||
expect(result).toBe("oracle")
|
||||
})
|
||||
|
||||
it("returns display name for librarian", () => {
|
||||
// #given config key "librarian"
|
||||
const configKey = "librarian"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "librarian"
|
||||
expect(result).toBe("librarian")
|
||||
})
|
||||
|
||||
it("returns display name for explore", () => {
|
||||
// #given config key "explore"
|
||||
const configKey = "explore"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "explore"
|
||||
expect(result).toBe("explore")
|
||||
})
|
||||
|
||||
it("returns display name for multimodal-looker", () => {
|
||||
// #given config key "multimodal-looker"
|
||||
const configKey = "multimodal-looker"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "multimodal-looker"
|
||||
expect(result).toBe("multimodal-looker")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AGENT_DISPLAY_NAMES", () => {
|
||||
it("contains all expected agent mappings", () => {
|
||||
// #given expected mappings
|
||||
const expectedMappings = {
|
||||
sisyphus: "Sisyphus (Ultraworker)",
|
||||
atlas: "Atlas (Plan Execution Orchestrator)",
|
||||
prometheus: "Prometheus (Plan Builder)",
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
momus: "Momus (Plan Reviewer)",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
}
|
||||
|
||||
// #when checking the constant
|
||||
// #then contains all expected mappings
|
||||
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
|
||||
})
|
||||
})
|
||||
37
src/shared/agent-display-names.ts
Normal file
37
src/shared/agent-display-names.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Agent config keys to display names mapping.
|
||||
* Config keys are lowercase (e.g., "sisyphus", "atlas").
|
||||
* Display names include suffixes for UI/logs (e.g., "Sisyphus (Ultraworker)").
|
||||
*/
|
||||
export const AGENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
sisyphus: "Sisyphus (Ultraworker)",
|
||||
atlas: "Atlas (Plan Execution Orchestrator)",
|
||||
prometheus: "Prometheus (Plan Builder)",
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
momus: "Momus (Plan Reviewer)",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for an agent config key.
|
||||
* Uses case-insensitive lookup for backward compatibility.
|
||||
* Returns original key if not found.
|
||||
*/
|
||||
export function getAgentDisplayName(configKey: string): string {
|
||||
// Try exact match first
|
||||
const exactMatch = AGENT_DISPLAY_NAMES[configKey]
|
||||
if (exactMatch !== undefined) return exactMatch
|
||||
|
||||
// Fall back to case-insensitive search
|
||||
const lowerKey = configKey.toLowerCase()
|
||||
for (const [k, v] of Object.entries(AGENT_DISPLAY_NAMES)) {
|
||||
if (k.toLowerCase() === lowerKey) return v
|
||||
}
|
||||
|
||||
// Unknown agent: return original key
|
||||
return configKey
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { findCaseInsensitive } from "./case-insensitive"
|
||||
|
||||
export function resolveAgentVariant(
|
||||
config: OhMyOpenCodeConfig,
|
||||
@@ -11,7 +12,7 @@ export function resolveAgentVariant(
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { variant?: string; category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides?.[agentName]
|
||||
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
|
||||
if (!agentOverride) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "./migration"
|
||||
|
||||
describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to Sisyphus", () => {
|
||||
test("migrates legacy OmO names to lowercase", () => {
|
||||
// #given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-5" },
|
||||
@@ -23,10 +23,10 @@ describe("migrateAgentNames", () => {
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Legacy names should be migrated to Sisyphus/Prometheus
|
||||
// #then: Legacy names should be migrated to lowercase
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
|
||||
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "custom prompt" })
|
||||
expect(migrated["sisyphus"]).toEqual({ temperature: 0.5 })
|
||||
expect(migrated["prometheus"]).toEqual({ prompt: "custom prompt" })
|
||||
expect(migrated["omo"]).toBeUndefined()
|
||||
expect(migrated["OmO"]).toBeUndefined()
|
||||
expect(migrated["OmO-Plan"]).toBeUndefined()
|
||||
@@ -62,9 +62,9 @@ describe("migrateAgentNames", () => {
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "test" })
|
||||
expect(migrated["Atlas"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["prometheus"]).toEqual({ prompt: "test" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
@@ -81,7 +81,7 @@ describe("migrateAgentNames", () => {
|
||||
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
|
||||
})
|
||||
|
||||
test("migrates orchestrator-sisyphus to Atlas", () => {
|
||||
test("migrates orchestrator-sisyphus to atlas", () => {
|
||||
// #given: Config with legacy orchestrator-sisyphus agent name
|
||||
const agents = {
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-5" },
|
||||
@@ -90,13 +90,13 @@ describe("migrateAgentNames", () => {
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: orchestrator-sisyphus should be migrated to Atlas
|
||||
// #then: orchestrator-sisyphus should be migrated to atlas
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["Atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates lowercase atlas to Atlas", () => {
|
||||
test("migrates lowercase atlas to atlas", () => {
|
||||
// #given: Config with lowercase atlas agent name
|
||||
const agents = {
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
@@ -105,10 +105,96 @@ describe("migrateAgentNames", () => {
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: lowercase atlas should be migrated to Atlas
|
||||
// #then: lowercase atlas should remain atlas (no change needed)
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("migrates Sisyphus variants to lowercase", () => {
|
||||
// #given agents config with "Sisyphus" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus"
|
||||
const agents = { "Sisyphus": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["Atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["atlas"]).toBeUndefined()
|
||||
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Sisyphus"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates omo key to sisyphus", () => {
|
||||
// #given agents config with "omo" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus"
|
||||
const agents = { "omo": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["omo"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates Atlas variants to lowercase", () => {
|
||||
// #given agents config with "Atlas" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "atlas"
|
||||
const agents = { "Atlas": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["atlas"]).toEqual({ model: "test" })
|
||||
expect(migrated["Atlas"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates Prometheus variants to lowercase", () => {
|
||||
// #given agents config with "Prometheus (Planner)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "prometheus"
|
||||
const agents = { "Prometheus (Planner)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["prometheus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Prometheus (Planner)"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates Metis variants to lowercase", () => {
|
||||
// #given agents config with "Metis (Plan Consultant)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "metis"
|
||||
const agents = { "Metis (Plan Consultant)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["metis"]).toEqual({ model: "test" })
|
||||
expect(migrated["Metis (Plan Consultant)"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates Momus variants to lowercase", () => {
|
||||
// #given agents config with "Momus (Plan Reviewer)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "momus"
|
||||
const agents = { "Momus (Plan Reviewer)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["momus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Momus (Plan Reviewer)"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates Sisyphus-Junior to lowercase", () => {
|
||||
// #given agents config with "Sisyphus-Junior" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus-junior"
|
||||
const agents = { "Sisyphus-Junior": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["sisyphus-junior"]).toEqual({ model: "test" })
|
||||
expect(migrated["Sisyphus-Junior"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves lowercase passthrough", () => {
|
||||
// #given agents config with "oracle" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key remains "oracle" (no change needed)
|
||||
const agents = { "oracle": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["oracle"]).toEqual({ model: "test" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,7 +335,7 @@ describe("migrateConfigFile", () => {
|
||||
// #then: Agent names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
expect(agents["sisyphus"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("migrates legacy hook names in disabled_hooks", () => {
|
||||
@@ -272,7 +358,7 @@ describe("migrateConfigFile", () => {
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
sisyphus_agent: { disabled: false },
|
||||
agents: {
|
||||
Sisyphus: { model: "test" },
|
||||
sisyphus: { model: "test" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-context-window-limit-recovery"],
|
||||
}
|
||||
@@ -303,8 +389,8 @@ describe("migrateConfigFile", () => {
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
expect(agents["Prometheus (Planner)"]).toBeDefined()
|
||||
expect(agents["sisyphus"]).toBeDefined()
|
||||
expect(agents["prometheus"]).toBeDefined()
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
@@ -312,13 +398,13 @@ describe("migrateConfigFile", () => {
|
||||
describe("migration maps", () => {
|
||||
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
|
||||
// #given/#when: Check AGENT_NAME_MAP
|
||||
// #then: Should contain all legacy → current mappings
|
||||
expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Prometheus (Planner)")
|
||||
expect(AGENT_NAME_MAP["omo-plan"]).toBe("Prometheus (Planner)")
|
||||
expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("Prometheus (Planner)")
|
||||
expect(AGENT_NAME_MAP["plan-consultant"]).toBe("Metis (Plan Consultant)")
|
||||
// #then: Should contain all legacy → lowercase mappings
|
||||
expect(AGENT_NAME_MAP["omo"]).toBe("sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO"]).toBe("sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("prometheus")
|
||||
expect(AGENT_NAME_MAP["omo-plan"]).toBe("prometheus")
|
||||
expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("prometheus")
|
||||
expect(AGENT_NAME_MAP["plan-consultant"]).toBe("metis")
|
||||
})
|
||||
|
||||
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
|
||||
@@ -622,29 +708,41 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
|
||||
test("does not write when no migration needed", () => {
|
||||
// #given: Config with no migrations needed
|
||||
const testConfigPath = "/tmp/test-config-no-migration.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
Sisyphus: { model: "test" },
|
||||
},
|
||||
}
|
||||
// #given: Config with no migrations needed
|
||||
const testConfigPath = "/tmp/test-config-no-migration.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "test" },
|
||||
},
|
||||
}
|
||||
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { Sisyphus: { model: "test" } } }, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { sisyphus: { model: "test" } } }, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
// Clean up any existing backup files from previous test runs
|
||||
const dir = path.dirname(testConfigPath)
|
||||
const basename = path.basename(testConfigPath)
|
||||
const existingFiles = fs.readdirSync(dir)
|
||||
const existingBackups = existingFiles.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
existingBackups.forEach((f) => {
|
||||
const backupPath = path.join(dir, f)
|
||||
try {
|
||||
fs.unlinkSync(backupPath)
|
||||
cleanupPaths.splice(cleanupPaths.indexOf(backupPath), 1)
|
||||
} catch {
|
||||
}
|
||||
})
|
||||
|
||||
// #then: Should not write or create backup
|
||||
expect(needsWrite).toBe(false)
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
const dir = path.dirname(testConfigPath)
|
||||
const basename = path.basename(testConfigPath)
|
||||
const files = fs.readdirSync(dir)
|
||||
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
expect(backupFiles.length).toBe(0)
|
||||
})
|
||||
// #then: Should not write or create backup
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const files = fs.readdirSync(dir)
|
||||
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
expect(backupFiles.length).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
@@ -3,35 +3,56 @@ import { log } from "./logger"
|
||||
|
||||
// Migration map: old keys → new keys (for backward compatibility)
|
||||
export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
omo: "Sisyphus",
|
||||
"OmO": "Sisyphus",
|
||||
sisyphus: "Sisyphus",
|
||||
"OmO-Plan": "Prometheus (Planner)",
|
||||
"omo-plan": "Prometheus (Planner)",
|
||||
"Planner-Sisyphus": "Prometheus (Planner)",
|
||||
"planner-sisyphus": "Prometheus (Planner)",
|
||||
prometheus: "Prometheus (Planner)",
|
||||
"plan-consultant": "Metis (Plan Consultant)",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
// Sisyphus variants → "sisyphus"
|
||||
omo: "sisyphus",
|
||||
OmO: "sisyphus",
|
||||
Sisyphus: "sisyphus",
|
||||
sisyphus: "sisyphus",
|
||||
|
||||
// Prometheus variants → "prometheus"
|
||||
"OmO-Plan": "prometheus",
|
||||
"omo-plan": "prometheus",
|
||||
"Planner-Sisyphus": "prometheus",
|
||||
"planner-sisyphus": "prometheus",
|
||||
"Prometheus (Planner)": "prometheus",
|
||||
prometheus: "prometheus",
|
||||
|
||||
// Atlas variants → "atlas"
|
||||
"orchestrator-sisyphus": "atlas",
|
||||
Atlas: "atlas",
|
||||
atlas: "atlas",
|
||||
|
||||
// Metis variants → "metis"
|
||||
"plan-consultant": "metis",
|
||||
"Metis (Plan Consultant)": "metis",
|
||||
metis: "metis",
|
||||
|
||||
// Momus variants → "momus"
|
||||
"Momus (Plan Reviewer)": "momus",
|
||||
momus: "momus",
|
||||
|
||||
// Sisyphus-Junior → "sisyphus-junior"
|
||||
"Sisyphus-Junior": "sisyphus-junior",
|
||||
"sisyphus-junior": "sisyphus-junior",
|
||||
|
||||
// Already lowercase - passthrough
|
||||
build: "build",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"orchestrator-sisyphus": "Atlas",
|
||||
atlas: "Atlas",
|
||||
}
|
||||
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"Sisyphus",
|
||||
"sisyphus", // was "Sisyphus"
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"Prometheus (Planner)",
|
||||
"Atlas",
|
||||
"metis", // was "Metis (Plan Consultant)"
|
||||
"momus", // was "Momus (Plan Reviewer)"
|
||||
"prometheus", // was "Prometheus (Planner)"
|
||||
"atlas", // was "Atlas"
|
||||
"build",
|
||||
])
|
||||
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
import { fetchAvailableModels, fuzzyMatchModel, __resetModelCache } from "./model-availability"
|
||||
|
||||
describe("fetchAvailableModels", () => {
|
||||
let mockClient: any
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
})
|
||||
|
||||
it("#given API returns list of models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
|
||||
const mockModels = [
|
||||
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro" },
|
||||
]
|
||||
mockClient = {
|
||||
model: {
|
||||
list: async () => mockModels,
|
||||
},
|
||||
afterEach(() => {
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(mockClient)
|
||||
function writeModelsCache(data: Record<string, any>) {
|
||||
const cacheDir = join(tempDir, "opencode")
|
||||
require("fs").mkdirSync(cacheDir, { recursive: true })
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
it("#given cache file with models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(3)
|
||||
@@ -29,77 +46,50 @@ describe("fetchAvailableModels", () => {
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given API fails #when fetchAvailableModels called #then returns empty Set without throwing", async () => {
|
||||
mockClient = {
|
||||
model: {
|
||||
list: async () => {
|
||||
throw new Error("API connection failed")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = await fetchAvailableModels(mockClient)
|
||||
it("#given cache file not found #when fetchAvailableModels called #then returns empty Set", async () => {
|
||||
const result = await fetchAvailableModels()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given API called twice #when second call made #then uses cached result without re-fetching", async () => {
|
||||
let callCount = 0
|
||||
const mockModels = [
|
||||
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
]
|
||||
mockClient = {
|
||||
model: {
|
||||
list: async () => {
|
||||
callCount++
|
||||
return mockModels
|
||||
},
|
||||
},
|
||||
}
|
||||
it("#given cache read twice #when second call made #then uses cached result", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
})
|
||||
|
||||
const result1 = await fetchAvailableModels(mockClient)
|
||||
const result2 = await fetchAvailableModels(mockClient)
|
||||
const result1 = await fetchAvailableModels()
|
||||
const result2 = await fetchAvailableModels()
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
expect(result1).toEqual(result2)
|
||||
expect(result1.has("openai/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given empty model list from API #when fetchAvailableModels called #then returns empty Set", async () => {
|
||||
mockClient = {
|
||||
model: {
|
||||
list: async () => [],
|
||||
},
|
||||
}
|
||||
it("#given empty providers in cache #when fetchAvailableModels called #then returns empty Set", async () => {
|
||||
writeModelsCache({})
|
||||
|
||||
const result = await fetchAvailableModels(mockClient)
|
||||
const result = await fetchAvailableModels()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given API returns models with various formats #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
|
||||
const mockModels = [
|
||||
{ id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash" },
|
||||
{ id: "opencode/grok-code", name: "Grok Code" },
|
||||
]
|
||||
mockClient = {
|
||||
model: {
|
||||
list: async () => mockModels,
|
||||
},
|
||||
}
|
||||
it("#given cache file with various providers #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2-codex": { id: "gpt-5.2-codex" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } },
|
||||
google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } },
|
||||
opencode: { id: "opencode", models: { "grok-code": { id: "grok-code" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(mockClient)
|
||||
const result = await fetchAvailableModels()
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
|
||||
expect(result.has("google/gemini-3-flash")).toBe(true)
|
||||
expect(result.has("opencode/grok-code")).toBe(true)
|
||||
expect(result.has("opencode/grok-code")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* Supports substring matching with provider filtering and priority-based selection
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
@@ -90,36 +93,62 @@ export function fuzzyMatchModel(
|
||||
|
||||
let cachedModels: Set<string> | null = null
|
||||
|
||||
export async function fetchAvailableModels(client: any): Promise<Set<string>> {
|
||||
function getOpenCodeCacheDir(): string {
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
if (xdgCache) return join(xdgCache, "opencode")
|
||||
return join(homedir(), ".cache", "opencode")
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(_client?: any): Promise<Set<string>> {
|
||||
log("[fetchAvailableModels] CALLED")
|
||||
|
||||
if (cachedModels !== null) {
|
||||
log("[fetchAvailableModels] returning cached models", { count: cachedModels.size, models: Array.from(cachedModels).slice(0, 20) })
|
||||
return cachedModels
|
||||
}
|
||||
|
||||
const modelSet = new Set<string>()
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
|
||||
log("[fetchAvailableModels] reading cache file", { cacheFile })
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[fetchAvailableModels] cache file not found, returning empty set")
|
||||
return modelSet
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await client.model.list()
|
||||
const modelSet = new Set<string>()
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, { id?: string; models?: Record<string, { id?: string }> }>
|
||||
|
||||
log("[fetchAvailableModels] raw response", { isArray: Array.isArray(models), length: Array.isArray(models) ? models.length : 0, sample: Array.isArray(models) ? models.slice(0, 5) : models })
|
||||
const providerIds = Object.keys(data)
|
||||
log("[fetchAvailableModels] providers found", { count: providerIds.length, providers: providerIds.slice(0, 10) })
|
||||
|
||||
if (Array.isArray(models)) {
|
||||
for (const model of models) {
|
||||
if (model.id && typeof model.id === "string") {
|
||||
modelSet.add(model.id)
|
||||
}
|
||||
for (const providerId of providerIds) {
|
||||
const provider = data[providerId]
|
||||
const models = provider?.models
|
||||
if (!models || typeof models !== "object") continue
|
||||
|
||||
for (const modelKey of Object.keys(models)) {
|
||||
modelSet.add(`${providerId}/${modelKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed models", { count: modelSet.size, models: Array.from(modelSet) })
|
||||
log("[fetchAvailableModels] parsed models", { count: modelSet.size, models: Array.from(modelSet).slice(0, 20) })
|
||||
|
||||
cachedModels = modelSet
|
||||
return modelSet
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] error", { error: String(err) })
|
||||
return new Set<string>()
|
||||
return modelSet
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetModelCache(): void {
|
||||
cachedModels = null
|
||||
}
|
||||
|
||||
export function isModelCacheAvailable(): boolean {
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("Sisyphus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - Sisyphus agent requirement
|
||||
const sisyphus = AGENT_MODEL_REQUIREMENTS["Sisyphus"]
|
||||
test("sisyphus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - sisyphus agent requirement
|
||||
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
// #when - accessing Sisyphus requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
@@ -54,19 +54,19 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.model).toBe("glm-4.7")
|
||||
})
|
||||
|
||||
test("explore has valid fallbackChain with gemini-3-flash-preview as primary", () => {
|
||||
test("explore has valid fallbackChain with claude-haiku-4-5 as primary", () => {
|
||||
// #given - explore agent requirement
|
||||
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
|
||||
|
||||
// #when - accessing explore requirement
|
||||
// #then - fallbackChain exists with gemini-3-flash-preview as first entry
|
||||
// #then - fallbackChain exists with claude-haiku-4-5 as first entry
|
||||
expect(explore).toBeDefined()
|
||||
expect(explore.fallbackChain).toBeArray()
|
||||
expect(explore.fallbackChain.length).toBeGreaterThan(0)
|
||||
|
||||
const primary = explore.fallbackChain[0]
|
||||
expect(primary.providers).toContain("google")
|
||||
expect(primary.model).toBe("gemini-3-flash-preview")
|
||||
expect(primary.providers).toContain("anthropic")
|
||||
expect(primary.model).toBe("claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("multimodal-looker has valid fallbackChain with gemini-3-flash-preview as primary", () => {
|
||||
@@ -84,9 +84,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.model).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("Prometheus (Planner) has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - Prometheus agent requirement
|
||||
const prometheus = AGENT_MODEL_REQUIREMENTS["Prometheus (Planner)"]
|
||||
test("prometheus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - prometheus agent requirement
|
||||
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
|
||||
|
||||
// #when - accessing Prometheus requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
@@ -100,9 +100,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("Metis (Plan Consultant) has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - Metis agent requirement
|
||||
const metis = AGENT_MODEL_REQUIREMENTS["Metis (Plan Consultant)"]
|
||||
test("metis has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - metis agent requirement
|
||||
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
|
||||
|
||||
// #when - accessing Metis requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
@@ -116,9 +116,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("Momus (Plan Reviewer) has valid fallbackChain with gpt-5.2 as primary", () => {
|
||||
// #given - Momus agent requirement
|
||||
const momus = AGENT_MODEL_REQUIREMENTS["Momus (Plan Reviewer)"]
|
||||
test("momus has valid fallbackChain with gpt-5.2 as primary", () => {
|
||||
// #given - momus agent requirement
|
||||
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
|
||||
|
||||
// #when - accessing Momus requirement
|
||||
// #then - fallbackChain exists with gpt-5.2 as first entry, variant medium
|
||||
@@ -132,9 +132,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("openai")
|
||||
})
|
||||
|
||||
test("Atlas has valid fallbackChain with claude-sonnet-4-5 as primary", () => {
|
||||
// #given - Atlas agent requirement
|
||||
const atlas = AGENT_MODEL_REQUIREMENTS["Atlas"]
|
||||
test("atlas has valid fallbackChain with claude-sonnet-4-5 as primary", () => {
|
||||
// #given - atlas agent requirement
|
||||
const atlas = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
// #when - accessing Atlas requirement
|
||||
// #then - fallbackChain exists with claude-sonnet-4-5 as first entry
|
||||
@@ -150,15 +150,15 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
test("all 9 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 9 agent names
|
||||
const expectedAgents = [
|
||||
"Sisyphus",
|
||||
"sisyphus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"Prometheus (Planner)",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"Atlas",
|
||||
"prometheus",
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
]
|
||||
|
||||
// #when - checking AGENT_MODEL_REQUIREMENTS
|
||||
|
||||
@@ -10,10 +10,11 @@ export type ModelRequirement = {
|
||||
}
|
||||
|
||||
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
Sisyphus: {
|
||||
sisyphus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
|
||||
],
|
||||
},
|
||||
@@ -33,40 +34,40 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
explore: {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode", "github-copilot"], model: "grok-code" },
|
||||
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode"], model: "grok-code" },
|
||||
],
|
||||
},
|
||||
"multimodal-looker": {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
},
|
||||
"Prometheus (Planner)": {
|
||||
prometheus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
|
||||
],
|
||||
},
|
||||
"Metis (Plan Consultant)": {
|
||||
metis: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview", variant: "max" },
|
||||
],
|
||||
},
|
||||
"Momus (Plan Reviewer)": {
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview", variant: "max" },
|
||||
],
|
||||
},
|
||||
Atlas: {
|
||||
atlas: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
@@ -101,13 +102,13 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.1-codex-mini" },
|
||||
{ providers: ["opencode"], model: "grok-code" },
|
||||
],
|
||||
},
|
||||
"unspecified-low": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
|
||||
],
|
||||
},
|
||||
@@ -122,6 +123,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -316,8 +316,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Step 3: First fallback entry (no availability match)", () => {
|
||||
test("returns first fallbackChain entry when no availability match found", () => {
|
||||
describe("Step 3: System default fallback (no availability match)", () => {
|
||||
test("returns system default when no availability match found in fallback chain", () => {
|
||||
// #given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -331,13 +331,13 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("anthropic/nonexistent-model")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain first entry (no availability match)", { model: "anthropic/nonexistent-model", variant: undefined })
|
||||
expect(result.model).toBe("google/gemini-3-pro")
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||
})
|
||||
|
||||
test("returns first fallbackChain entry when availableModels is empty", () => {
|
||||
// #given
|
||||
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
|
||||
// #given - empty availableModels simulates CI environment without model cache
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
@@ -349,7 +349,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// #then - should use first fallback entry, not system default
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
})
|
||||
@@ -431,7 +431,7 @@ describe("resolveModelWithFallback", () => {
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("falls through to first fallbackChain entry when none match availability", () => {
|
||||
test("falls through to system default when none match availability", () => {
|
||||
// #given
|
||||
const availableModels = new Set(["other/model"])
|
||||
|
||||
@@ -447,8 +447,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("openai/gpt-5.2")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result.model).toBe("system/default")
|
||||
expect(result.source).toBe("system-default")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,15 @@ export function resolveModelWithFallback(
|
||||
|
||||
// Step 2: Provider fallback chain (with availability check)
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
// If availableModels is empty (no cache), use first fallback entry directly without availability check
|
||||
if (availableModels.size === 0) {
|
||||
const firstEntry = fallbackChain[0]
|
||||
const firstProvider = firstEntry.providers[0]
|
||||
const model = `${firstProvider}/${firstEntry.model}`
|
||||
log("Model resolved via fallback chain (no cache, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
|
||||
return { model, source: "provider-fallback", variant: firstEntry.variant }
|
||||
}
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
const fullModel = `${provider}/${entry.model}`
|
||||
@@ -63,15 +72,8 @@ export function resolveModelWithFallback(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Use first entry in fallbackChain as fallback (no availability match found)
|
||||
// This ensures category/agent intent is honored even if availableModels is incomplete
|
||||
const firstEntry = fallbackChain[0]
|
||||
if (firstEntry.providers.length > 0) {
|
||||
const fallbackModel = `${firstEntry.providers[0]}/${firstEntry.model}`
|
||||
log("Model resolved via fallback chain first entry (no availability match)", { model: fallbackModel, variant: firstEntry.variant })
|
||||
return { model: fallbackModel, source: "provider-fallback", variant: firstEntry.variant }
|
||||
}
|
||||
// No match found in fallback chain - fall through to system default
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
|
||||
// Step 4: System default
|
||||
|
||||
@@ -15,7 +15,7 @@ tools/
|
||||
│ └── constants.ts # Fixed values
|
||||
├── lsp/ # 6 tools: definition, references, symbols, diagnostics, rename
|
||||
├── ast-grep/ # 2 tools: search, replace (25 languages)
|
||||
├── delegate-task/ # Category-based routing (1038 lines)
|
||||
├── delegate-task/ # Category-based routing (1039 lines)
|
||||
├── session-manager/ # 4 tools: list, read, search, info
|
||||
├── grep/ # Custom grep with timeout
|
||||
├── glob/ # 60s timeout, 100 file limit
|
||||
|
||||
@@ -360,6 +360,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ id: "anthropic/claude-opus-4-5" }] },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -410,6 +411,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ id: "anthropic/claude-opus-4-5" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_default_variant" } }),
|
||||
@@ -958,6 +960,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ id: "google/gemini-3-pro-preview" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
||||
@@ -1141,6 +1144,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ id: "google/gemini-3-pro-preview" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
||||
@@ -1205,6 +1209,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ id: "google/gemini-3-flash-preview" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_writing_gemini" } }),
|
||||
|
||||
@@ -156,6 +156,7 @@ export interface DelegateTaskToolOptions {
|
||||
directory: string
|
||||
userCategories?: CategoriesConfig
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export interface BuildSystemContentInput {
|
||||
@@ -178,7 +179,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
|
||||
}
|
||||
|
||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||
const { manager, client, directory, userCategories, gitMasterConfig } = options
|
||||
const { manager, client, directory, userCategories, gitMasterConfig, sisyphusJuniorModel } = options
|
||||
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const categoryNames = Object.keys(allCategories)
|
||||
@@ -513,7 +514,7 @@ To resume this session: resume="${args.resume}"`
|
||||
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
|
||||
} else {
|
||||
const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({
|
||||
userModel: userCategories?.[args.category]?.model,
|
||||
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
|
||||
fallbackChain: requirement.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
|
||||
import type { ResolvedServer, ServerLookupResult } from "./types"
|
||||
import { getOpenCodeConfigDir } from "../../shared"
|
||||
import { getOpenCodeConfigDir, getDataDir } from "../../shared"
|
||||
|
||||
interface LspEntry {
|
||||
disabled?: boolean
|
||||
@@ -201,10 +201,12 @@ export function isServerInstalled(command: string[]): boolean {
|
||||
|
||||
const cwd = process.cwd()
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const dataDir = join(getDataDir(), "opencode")
|
||||
const additionalBases = [
|
||||
join(cwd, "node_modules", ".bin"),
|
||||
join(configDir, "bin"),
|
||||
join(configDir, "node_modules", ".bin"),
|
||||
join(dataDir, "bin"),
|
||||
]
|
||||
|
||||
for (const base of additionalBases) {
|
||||
|
||||
@@ -20,6 +20,21 @@ Test skill body content`
|
||||
},
|
||||
}))
|
||||
|
||||
function createMockSkill(name: string, options: { agent?: string } = {}): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: "Test template",
|
||||
agent: options.agent,
|
||||
},
|
||||
scope: "opencode-project",
|
||||
}
|
||||
}
|
||||
|
||||
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
@@ -42,6 +57,59 @@ const mockContext = {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("skill tool - agent restriction", () => {
|
||||
it("allows skill without agent restriction to any agent", async () => {
|
||||
// #given
|
||||
const loadedSkills = [createMockSkill("public-skill")]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
const context = { ...mockContext, agent: "any-agent" }
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "public-skill" }, context)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("public-skill")
|
||||
})
|
||||
|
||||
it("allows skill when agent matches restriction", async () => {
|
||||
// #given
|
||||
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
const context = { ...mockContext, agent: "sisyphus" }
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "restricted-skill" }, context)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("restricted-skill")
|
||||
})
|
||||
|
||||
it("throws error when agent does not match restriction", async () => {
|
||||
// #given
|
||||
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
const context = { ...mockContext, agent: "oracle" }
|
||||
|
||||
// #when / #then
|
||||
await expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
|
||||
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
||||
)
|
||||
})
|
||||
|
||||
it("throws error when context agent is undefined for restricted skill", async () => {
|
||||
// #given
|
||||
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string }
|
||||
|
||||
// #when / #then
|
||||
await expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
|
||||
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("skill tool - MCP schema display", () => {
|
||||
let manager: SkillMcpManager
|
||||
let loadedSkills: LoadedSkill[]
|
||||
|
||||
@@ -156,7 +156,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
args: {
|
||||
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
|
||||
},
|
||||
async execute(args: SkillArgs) {
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
const skills = await getSkills()
|
||||
const skill = skills.find(s => s.name === args.name)
|
||||
|
||||
@@ -165,6 +165,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) {
|
||||
throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`)
|
||||
}
|
||||
|
||||
let body = await extractSkillBody(skill)
|
||||
|
||||
if (args.name === "git-master") {
|
||||
|
||||
Reference in New Issue
Block a user