Compare commits
99 Commits
v3.13.1
...
feat/athen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a14bd6d68 | ||
|
|
1c125ec3ef | ||
|
|
647f691fe2 | ||
|
|
a391f44420 | ||
|
|
94b4a4f850 | ||
|
|
9fde370838 | ||
|
|
b6ee7f09b1 | ||
|
|
28bcab066e | ||
|
|
b5cb50b561 | ||
|
|
8242500856 | ||
|
|
6d688ac0ae | ||
|
|
da3e80464d | ||
|
|
23df6bd255 | ||
|
|
7895361f42 | ||
|
|
90919bf359 | ||
|
|
32f2c688e7 | ||
|
|
e6d0484e57 | ||
|
|
abd62472cf | ||
|
|
b1e099130a | ||
|
|
09fb364bfb | ||
|
|
d1ff8b1e3f | ||
|
|
6e42b553cc | ||
|
|
02ab83f4d4 | ||
|
|
ce1bffbc4d | ||
|
|
4d4680be3c | ||
|
|
ce877ec0d8 | ||
|
|
ec20a82b4e | ||
|
|
5043cc21ac | ||
|
|
8df3a2876a | ||
|
|
087e33d086 | ||
|
|
46c6e1dcf6 | ||
|
|
5befb60229 | ||
|
|
55df2179b8 | ||
|
|
76420b36ab | ||
|
|
a15f6076bc | ||
|
|
7c0289d7bc | ||
|
|
5e9231e251 | ||
|
|
f04cc0fa9c | ||
|
|
613ef8eee8 | ||
|
|
99b398063c | ||
|
|
2af9324400 | ||
|
|
7a52639a1b | ||
|
|
5df54bced4 | ||
|
|
cd04e6a19e | ||
|
|
e974b151c1 | ||
|
|
6f213a0ac9 | ||
|
|
71004e88d3 | ||
|
|
5898d36321 | ||
|
|
90aa3e4489 | ||
|
|
2268ba45f9 | ||
|
|
aca9342722 | ||
|
|
a3519c3a14 | ||
|
|
e610d88558 | ||
|
|
ed09bf5462 | ||
|
|
1d48518b41 | ||
|
|
d6d4cece9d | ||
|
|
9d930656da | ||
|
|
f86b8b3336 | ||
|
|
1f5d7702ff | ||
|
|
1e70f64001 | ||
|
|
d4f962b55d | ||
|
|
fb085538eb | ||
|
|
e5c5438a44 | ||
|
|
a77a16c494 | ||
|
|
7761e48dca | ||
|
|
d7a1945b27 | ||
|
|
44fb114370 | ||
|
|
bf804b0626 | ||
|
|
c4aa380855 | ||
|
|
993bd51eac | ||
|
|
732743960f | ||
|
|
bff573488c | ||
|
|
f16d55ad95 | ||
|
|
3c49bf3a8c | ||
|
|
29a7bc2d31 | ||
|
|
62d2704009 | ||
|
|
db32bad004 | ||
|
|
5777bf9894 | ||
|
|
07ea8debdc | ||
|
|
0d52519293 | ||
|
|
031503bb8c | ||
|
|
5986583641 | ||
|
|
3773e370ec | ||
|
|
23a30e86f2 | ||
|
|
04637ff0f1 | ||
|
|
0d96e0d3bc | ||
|
|
719a58270b | ||
|
|
7e3c36ee03 | ||
|
|
11d942f3a2 | ||
|
|
2b6b08345a | ||
|
|
abdd39da00 | ||
|
|
711aac0f0a | ||
|
|
f2b26e5346 | ||
|
|
a7a7799b44 | ||
|
|
1e0823a0fc | ||
|
|
edfa411684 | ||
|
|
6d8bc95fa6 | ||
|
|
229c6b0cdb | ||
|
|
3eb97110c6 |
46
.github/workflows/refresh-model-capabilities.yml
vendored
Normal file
46
.github/workflows/refresh-model-capabilities.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Refresh Model Capabilities
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'code-yeongyu/oh-my-openagent'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Refresh bundled model capabilities snapshot
|
||||
run: bun run build:model-capabilities
|
||||
|
||||
- name: Validate capability guardrails
|
||||
run: bun run test:model-capabilities
|
||||
|
||||
- name: Create refresh pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: "chore: refresh model capabilities snapshot"
|
||||
title: "chore: refresh model capabilities snapshot"
|
||||
body: |
|
||||
Automated refresh of `src/generated/model-capabilities.generated.json` from `https://models.dev/api.json`.
|
||||
|
||||
This keeps the bundled capability snapshot aligned with upstream model metadata without relying on manual refreshes.
|
||||
branch: automation/refresh-model-capabilities
|
||||
delete-branch: true
|
||||
labels: |
|
||||
maintenance
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,10 +92,10 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
|
||||
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| --------------------- | ------------------ | ---------------------------------------------- | ----------------------------------------------------- |
|
||||
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
|
||||
| **Librarian** | Docs/code search | opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
|
||||
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
|
||||
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
|
||||
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.7-highspeed → MiniMax M2.7 → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
|
||||
| **Librarian** | Docs/code search | opencode-go/minimax-m2.7 → MiniMax M2.7-highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
|
||||
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
|
||||
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → MiniMax M2.7 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
|
||||
|
||||
---
|
||||
|
||||
@@ -131,7 +131,8 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
|
||||
| **Gemini 3.1 Pro** | Excels at visual/frontend tasks. Different reasoning style. Default for `visual-engineering` and `artistry`. |
|
||||
| **Gemini 3 Flash** | Fast. Good for doc search and light tasks. |
|
||||
| **Grok Code Fast 1** | Blazing fast code grep. Default for Explore agent. |
|
||||
| **MiniMax M2.5** | Fast and smart. Good for utility tasks and search/retrieval. |
|
||||
| **MiniMax M2.7** | Fast and smart. Good for utility tasks and search/retrieval. Upgraded from M2.5 with better reasoning. |
|
||||
| **MiniMax M2.7 Highspeed** | Ultra-fast variant. Optimized for latency-sensitive tasks like codebase grep. |
|
||||
|
||||
### OpenCode Go
|
||||
|
||||
@@ -143,11 +144,11 @@ A premium subscription tier ($10/month) that provides reliable access to Chinese
|
||||
| ------------------------ | --------------------------------------------------------------------- |
|
||||
| **opencode-go/kimi-k2.5** | Vision-capable, Claude-like reasoning. Used by Sisyphus, Atlas, Sisyphus-Junior, Multimodal Looker. |
|
||||
| **opencode-go/glm-5** | Text-only orchestration model. Used by Oracle, Prometheus, Metis, Momus. |
|
||||
| **opencode-go/minimax-m2.5** | Ultra-cheap, fast responses. Used by Librarian, Explore for utility work. |
|
||||
| **opencode-go/minimax-m2.7** | Ultra-cheap, fast responses. Used by Librarian, Explore, Atlas, Sisyphus-Junior for utility work. |
|
||||
|
||||
**When It Gets Used:**
|
||||
|
||||
OpenCode Go models appear in fallback chains as intermediate options. They bridge the gap between premium Claude access and free-tier alternatives. The system tries OpenCode Go models before falling back to free tiers (MiniMax Free, Big Pickle) or GPT alternatives.
|
||||
OpenCode Go models appear in fallback chains as intermediate options. They bridge the gap between premium Claude access and free-tier alternatives. The system tries OpenCode Go models before falling back to free tiers (MiniMax M2.7-highspeed, Big Pickle) or GPT alternatives.
|
||||
|
||||
**Go-Only Scenarios:**
|
||||
|
||||
@@ -155,7 +156,7 @@ Some model identifiers like `k2p5` (paid Kimi K2.5) and `glm-5` may only be avai
|
||||
|
||||
### About Free-Tier Fallbacks
|
||||
|
||||
You may see model names like `kimi-k2.5-free`, `minimax-m2.5-free`, or `big-pickle` (GLM 4.6) in the source code or logs. These are free-tier versions of the same model families, served through the OpenCode Zen provider. They exist as lower-priority entries in fallback chains.
|
||||
You may see model names like `kimi-k2.5-free`, `minimax-m2.7-highspeed`, or `big-pickle` (GLM 4.6) in the source code or logs. These are free-tier or speed-optimized versions of the same model families. They exist as lower-priority entries in fallback chains.
|
||||
|
||||
You don't need to configure them. The system includes them so it degrades gracefully when you don't have every paid subscription. If you have the paid version, the paid version is always preferred.
|
||||
|
||||
@@ -171,7 +172,7 @@ When agents delegate work, they don't pick a model name — they pick a **catego
|
||||
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 |
|
||||
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
|
||||
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
|
||||
| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
|
||||
| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.7 → GPT-5-Nano |
|
||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
|
||||
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash |
|
||||
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |
|
||||
|
||||
@@ -69,7 +69,7 @@ Ask the user these questions to determine CLI options:
|
||||
- If **no** → `--zai-coding-plan=no` (default)
|
||||
|
||||
7. **Do you have an OpenCode Go subscription?**
|
||||
- OpenCode Go is a $10/month subscription providing access to GLM-5, Kimi K2.5, and MiniMax M2.5 models
|
||||
- OpenCode Go is a $10/month subscription providing access to GLM-5, Kimi K2.5, and MiniMax M2.7 models
|
||||
- If **yes** → `--opencode-go=yes`
|
||||
- If **no** → `--opencode-go=no` (default)
|
||||
|
||||
@@ -205,7 +205,7 @@ When GitHub Copilot is the best available provider, oh-my-openagent uses these m
|
||||
|
||||
| Agent | Model |
|
||||
| ------------- | --------------------------------- |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4-6` |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.6` |
|
||||
| **Oracle** | `github-copilot/gpt-5.4` |
|
||||
| **Explore** | `github-copilot/grok-code-fast-1` |
|
||||
| **Librarian** | `github-copilot/gemini-3-flash` |
|
||||
@@ -227,7 +227,7 @@ If Z.ai is your main provider, the most important fallbacks are:
|
||||
|
||||
#### OpenCode Zen
|
||||
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, and `opencode/minimax-m2.5-free`.
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, and `opencode/minimax-m2.7-highspeed`.
|
||||
|
||||
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
|
||||
|
||||
@@ -236,7 +236,7 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
|
||||
| **Sisyphus** | `opencode/claude-opus-4-6` |
|
||||
| **Oracle** | `opencode/gpt-5.4` |
|
||||
| **Explore** | `opencode/gpt-5-nano` |
|
||||
| **Librarian** | `opencode/minimax-m2.5-free` / `opencode/big-pickle` |
|
||||
| **Librarian** | `opencode/minimax-m2.7-highspeed` / `opencode/big-pickle` |
|
||||
|
||||
##### Setup
|
||||
|
||||
@@ -296,8 +296,8 @@ Not all models behave the same way. Understanding which models are "similar" hel
|
||||
| --------------------- | -------------------------------- | ----------------------------------------------------------- |
|
||||
| **Gemini 3.1 Pro** | google, github-copilot, opencode | Excels at visual/frontend tasks. Different reasoning style. |
|
||||
| **Gemini 3 Flash** | google, github-copilot, opencode | Fast, good for doc search and light tasks. |
|
||||
| **MiniMax M2.5** | venice | Fast and smart. Good for utility tasks. |
|
||||
| **MiniMax M2.5 Free** | opencode | Free-tier MiniMax. Fast for search/retrieval. |
|
||||
| **MiniMax M2.7** | venice, opencode-go | Fast and smart. Good for utility tasks. Upgraded from M2.5. |
|
||||
| **MiniMax M2.7 Highspeed** | opencode | Ultra-fast MiniMax variant. Optimized for latency. |
|
||||
|
||||
**Speed-Focused Models**:
|
||||
|
||||
@@ -305,7 +305,7 @@ Not all models behave the same way. Understanding which models are "similar" hel
|
||||
| ----------------------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Grok Code Fast 1** | github-copilot, venice | Very fast | Optimized for code grep/search. Default for Explore. |
|
||||
| **Claude Haiku 4.5** | anthropic, opencode | Fast | Good balance of speed and intelligence. |
|
||||
| **MiniMax M2.5 (Free)** | opencode, venice | Fast | Smart for its speed class. |
|
||||
| **MiniMax M2.7 Highspeed** | opencode | Very fast | Ultra-fast MiniMax variant. Smart for its speed class. |
|
||||
| **GPT-5.3-codex-spark** | openai | Extremely fast | Blazing fast but compacts so aggressively that oh-my-openagent's context management doesn't work well with it. Not recommended for omo agents. |
|
||||
|
||||
#### What Each Agent Does and Which Model It Got
|
||||
@@ -344,8 +344,8 @@ These agents do search, grep, and retrieval. They intentionally use fast, cheap
|
||||
|
||||
| Agent | Role | Default Chain | Design Rationale |
|
||||
| --------------------- | ------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| **Explore** | Fast codebase grep | MiniMax M2.5 Free → Grok Code Fast → MiniMax M2.5 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. |
|
||||
| **Librarian** | Docs/code search | MiniMax M2.5 Free → Gemini Flash → Big Pickle | Entirely free-tier. Doc retrieval doesn't need deep reasoning. |
|
||||
| **Explore** | Fast codebase grep | Grok Code Fast → MiniMax M2.7-highspeed → MiniMax M2.7 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. |
|
||||
| **Librarian** | Docs/code search | MiniMax M2.7 → MiniMax M2.7-highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. MiniMax is fast. |
|
||||
| **Multimodal Looker** | Vision/screenshots | Kimi K2.5 → Kimi Free → Gemini Flash → GPT-5.4 → GLM-4.6v | Kimi excels at multimodal understanding. |
|
||||
|
||||
#### Why Different Models Need Different Prompts
|
||||
|
||||
@@ -221,7 +221,7 @@ You can override specific agents or categories in your config:
|
||||
**Different-behavior models**:
|
||||
|
||||
- Gemini 3.1 Pro — excels at visual/frontend tasks
|
||||
- MiniMax M2.5 — fast and smart for utility tasks
|
||||
- MiniMax M2.7 / M2.7-highspeed — fast and smart for utility tasks
|
||||
- Grok Code Fast 1 — optimized for code grep/search
|
||||
|
||||
See the [Agent-Model Matching Guide](./agent-model-matching.md) for complete details on which models work best for each agent, safe vs dangerous overrides, and provider priority chains.
|
||||
|
||||
33
docs/model-capabilities-maintenance.md
Normal file
33
docs/model-capabilities-maintenance.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Model Capabilities Maintenance
|
||||
|
||||
This project treats model capability resolution as a layered system:
|
||||
|
||||
1. runtime metadata from connected providers
|
||||
2. `models.dev` bundled/runtime snapshot data
|
||||
3. explicit compatibility aliases
|
||||
4. heuristic fallback as the last resort
|
||||
|
||||
## Internal policy
|
||||
|
||||
- Built-in OmO agent/category requirement models must use canonical model IDs.
|
||||
- Aliases exist only to preserve compatibility with historical OmO names or provider-specific decorations.
|
||||
- New decorated names like `-high`, `-low`, or `-thinking` should not be added to built-in requirements when a canonical model ID plus structured settings can express the same thing.
|
||||
- If a provider or config input still uses an alias, normalize it at the edge and continue internally with the canonical ID.
|
||||
|
||||
## When adding an alias
|
||||
|
||||
- Add the alias rule to `src/shared/model-capability-aliases.ts`.
|
||||
- Include a rationale for why the alias exists.
|
||||
- Add or update tests so the alias is covered explicitly.
|
||||
- Ensure the alias canonical target exists in the bundled `models.dev` snapshot.
|
||||
|
||||
## Guardrails
|
||||
|
||||
`bun run test:model-capabilities` enforces the following invariants:
|
||||
|
||||
- exact alias targets must exist in the bundled snapshot
|
||||
- exact alias keys must not silently become canonical `models.dev` IDs
|
||||
- pattern aliases must not rewrite canonical snapshot IDs
|
||||
- built-in requirement models must stay canonical and snapshot-backed
|
||||
|
||||
The scheduled `refresh-model-capabilities` workflow runs these guardrails before opening an automated snapshot refresh PR.
|
||||
@@ -270,8 +270,8 @@ Disable categories: `{ "disabled_categories": ["ultrabrain"] }`
|
||||
| **Sisyphus** | `claude-opus-4-6` | `claude-opus-4-6` → `glm-5` → `big-pickle` |
|
||||
| **Hephaestus** | `gpt-5.3-codex` | `gpt-5.3-codex` → `gpt-5.4` (GitHub Copilot fallback) |
|
||||
| **oracle** | `gpt-5.4` | `gpt-5.4` → `gemini-3.1-pro` → `claude-opus-4-6` |
|
||||
| **librarian** | `gemini-3-flash` | `gemini-3-flash` → `minimax-m2.5-free` → `big-pickle` |
|
||||
| **explore** | `grok-code-fast-1` | `grok-code-fast-1` → `minimax-m2.5-free` → `claude-haiku-4-5` → `gpt-5-nano` |
|
||||
| **librarian** | `minimax-m2.7` | `minimax-m2.7` → `minimax-m2.7-highspeed` → `claude-haiku-4-5` → `gpt-5-nano` |
|
||||
| **explore** | `grok-code-fast-1` | `grok-code-fast-1` → `minimax-m2.7-highspeed` → `minimax-m2.7` → `claude-haiku-4-5` → `gpt-5-nano` |
|
||||
| **multimodal-looker** | `gpt-5.3-codex` | `gpt-5.3-codex` → `k2p5` → `gemini-3-flash` → `glm-4.6v` → `gpt-5-nano` |
|
||||
| **Prometheus** | `claude-opus-4-6` | `claude-opus-4-6` → `gpt-5.4` → `gemini-3.1-pro` |
|
||||
| **Metis** | `claude-opus-4-6` | `claude-opus-4-6` → `gpt-5.4` → `gemini-3.1-pro` |
|
||||
@@ -286,10 +286,10 @@ Disable categories: `{ "disabled_categories": ["ultrabrain"] }`
|
||||
| **ultrabrain** | `gpt-5.4` | `gpt-5.4` → `gemini-3.1-pro` → `claude-opus-4-6` |
|
||||
| **deep** | `gpt-5.3-codex` | `gpt-5.3-codex` → `claude-opus-4-6` → `gemini-3.1-pro` |
|
||||
| **artistry** | `gemini-3.1-pro` | `gemini-3.1-pro` → `claude-opus-4-6` → `gpt-5.4` |
|
||||
| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini` → `claude-haiku-4-5` → `gemini-3-flash` → `minimax-m2.5` → `gpt-5-nano` |
|
||||
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6` → `gpt-5.3-codex` → `gemini-3-flash` |
|
||||
| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini` → `claude-haiku-4-5` → `gemini-3-flash` → `minimax-m2.7` → `gpt-5-nano` |
|
||||
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6` → `gpt-5.3-codex` → `gemini-3-flash` → `minimax-m2.7` |
|
||||
| **unspecified-high** | `claude-opus-4-6` | `claude-opus-4-6` → `gpt-5.4 (high)` → `glm-5` → `k2p5` → `kimi-k2.5` |
|
||||
| **writing** | `gemini-3-flash` | `gemini-3-flash` → `claude-sonnet-4-6` |
|
||||
| **writing** | `gemini-3-flash` | `gemini-3-flash` → `claude-sonnet-4-6` → `minimax-m2.7` |
|
||||
|
||||
Run `bunx oh-my-openagent doctor --verbose` to see effective model resolution for your config.
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Oh-My-OpenAgent provides 11 specialized AI agents. Each has distinct expertise,
|
||||
| **Sisyphus** | `claude-opus-4-6` | The default orchestrator. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: `glm-5` → `big-pickle`. |
|
||||
| **Hephaestus** | `gpt-5.3-codex` | The Legitimate Craftsman. Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Fallback: `gpt-5.4` on GitHub Copilot. Requires a GPT-capable provider. |
|
||||
| **Oracle** | `gpt-5.4` | Architecture decisions, code review, debugging. Read-only consultation with stellar logical reasoning and deep analysis. Inspired by AmpCode. Fallback: `gemini-3.1-pro` → `claude-opus-4-6`. |
|
||||
| **Librarian** | `gemini-3-flash` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: `minimax-m2.5-free` → `big-pickle`. |
|
||||
| **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Fallback: `minimax-m2.5-free` → `claude-haiku-4-5` → `gpt-5-nano`. |
|
||||
| **Librarian** | `minimax-m2.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: `minimax-m2.7-highspeed` → `claude-haiku-4-5` → `gpt-5-nano`. |
|
||||
| **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Fallback: `minimax-m2.7-highspeed` → `minimax-m2.7` → `claude-haiku-4-5` → `gpt-5-nano`. |
|
||||
| **Multimodal-Looker** | `gpt-5.3-codex` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: `k2p5` → `gemini-3-flash` → `glm-4.6v` → `gpt-5-nano`. |
|
||||
|
||||
### Planning Agents
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Model Settings Compatibility Resolver Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Centralize compatibility handling for `variant` and `reasoningEffort` so an already-selected model receives the best valid settings for that exact model.
|
||||
|
||||
**Architecture:** Introduce a pure shared resolver in `src/shared/` that computes compatible settings and records downgrades/removals. Integrate it first in `chat.params`, then keep Claude-specific effort logic as a thin layer rather than a special-case policy owner.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun test, existing shared model normalization/utilities, OpenCode plugin `chat.params` path.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create the pure compatibility resolver
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/model-settings-compatibility.ts`
|
||||
- Create: `src/shared/model-settings-compatibility.test.ts`
|
||||
- Modify: `src/shared/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for exact keep behavior**
|
||||
- [ ] **Step 2: Write failing tests for downgrade behavior (`max` -> `high`, `xhigh` -> `high` where needed)**
|
||||
- [ ] **Step 3: Write failing tests for unsupported-value removal**
|
||||
- [ ] **Step 4: Write failing tests for model-family distinctions (Opus vs Sonnet/Haiku, GPT-family variants)**
|
||||
- [ ] **Step 5: Implement the pure resolver with explicit capability ladders**
|
||||
- [ ] **Step 6: Export the resolver from `src/shared/index.ts`**
|
||||
- [ ] **Step 7: Run `bun test src/shared/model-settings-compatibility.test.ts`**
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
### Task 2: Integrate resolver into chat.params
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/plugin/chat-params.ts`
|
||||
- Modify: `src/plugin/chat-params.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests showing `chat.params` applies resolver output to runtime settings**
|
||||
- [ ] **Step 2: Ensure tests cover both `variant` and `reasoningEffort` decisions**
|
||||
- [ ] **Step 3: Update `chat-params.ts` to call the shared resolver before hook-specific adjustments**
|
||||
- [ ] **Step 4: Preserve existing prompt-param-store merging behavior**
|
||||
- [ ] **Step 5: Run `bun test src/plugin/chat-params.test.ts`**
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
### Task 3: Re-scope anthropic-effort around the resolver
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hooks/anthropic-effort/hook.ts`
|
||||
- Modify: `src/hooks/anthropic-effort/index.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests that codify the intended remaining Anthropic-specific behavior after centralization**
|
||||
- [ ] **Step 2: Reduce `anthropic-effort` to Claude/Anthropic-specific effort injection where still needed**
|
||||
- [ ] **Step 3: Remove duplicated compatibility policy from the hook if the shared resolver now owns it**
|
||||
- [ ] **Step 4: Run `bun test src/hooks/anthropic-effort/index.test.ts`**
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
### Task 4: Add integration/regression coverage across real request paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/plugin/chat-params.test.ts`
|
||||
- Modify: `src/hooks/anthropic-effort/index.test.ts`
|
||||
- Add tests only where needed in nearby suites
|
||||
|
||||
- [ ] **Step 1: Add regression test for non-Opus Claude with `variant=max` resolving to compatible settings without ad hoc path-only logic**
|
||||
- [ ] **Step 2: Add regression test for GPT-style `reasoningEffort` compatibility**
|
||||
- [ ] **Step 3: Add regression test showing supported values remain unchanged**
|
||||
- [ ] **Step 4: Run the focused test set**
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
### Task 5: Verify full quality bar
|
||||
|
||||
**Files:**
|
||||
- No intended code changes
|
||||
|
||||
- [ ] **Step 1: Run `bun run typecheck`**
|
||||
- [ ] **Step 2: Run a focused suite for the touched files**
|
||||
- [ ] **Step 3: If clean, run `bun test`**
|
||||
- [ ] **Step 4: Review diff for accidental scope creep**
|
||||
- [ ] **Step 5: Commit any final cleanup**
|
||||
|
||||
### Task 6: Prepare PR metadata
|
||||
|
||||
**Files:**
|
||||
- No repo file change required unless docs are updated further
|
||||
|
||||
- [ ] **Step 1: Write a human summary explaining this is settings compatibility, not model fallback**
|
||||
- [ ] **Step 2: Document scope: Phase 1 covers `variant` and `reasoningEffort` only**
|
||||
- [ ] **Step 3: Document explicit non-goals: no model switching, no automatic upscaling in Phase 1**
|
||||
- [ ] **Step 4: Request review**
|
||||
@@ -0,0 +1,164 @@
|
||||
# Model Settings Compatibility Resolver Design
|
||||
|
||||
## Goal
|
||||
|
||||
Introduce a central resolver that takes an already-selected model and a set of desired model settings, then returns the best compatible configuration for that exact model.
|
||||
|
||||
This is explicitly separate from model fallback.
|
||||
|
||||
## Problem
|
||||
|
||||
Today, logic for `variant` and `reasoningEffort` compatibility is scattered across multiple places:
|
||||
- `hooks/anthropic-effort`
|
||||
- `plugin/chat-params`
|
||||
- agent/category/fallback config layers
|
||||
- delegate/background prompt plumbing
|
||||
|
||||
That creates inconsistent behavior:
|
||||
- some paths clamp unsupported levels
|
||||
- some paths pass them through unchanged
|
||||
- some paths silently drop them
|
||||
- some paths use model-family-specific assumptions that do not generalize
|
||||
|
||||
The result is brittle request behavior even when the chosen model itself is valid.
|
||||
|
||||
## Scope
|
||||
|
||||
Phase 1 covers only:
|
||||
- `variant`
|
||||
- `reasoningEffort`
|
||||
|
||||
Out of scope for Phase 1:
|
||||
- model fallback itself
|
||||
- `thinking`
|
||||
- `maxTokens`
|
||||
- `temperature`
|
||||
- `top_p`
|
||||
- automatic upward remapping of settings
|
||||
|
||||
## Desired behavior
|
||||
|
||||
Given a fixed model and desired settings:
|
||||
1. If a desired value is supported, keep it.
|
||||
2. If not supported, downgrade to the nearest lower compatible value.
|
||||
3. If no compatible value exists, drop the field.
|
||||
4. Do not switch models.
|
||||
5. Do not automatically upgrade settings in Phase 1.
|
||||
|
||||
## Architecture
|
||||
|
||||
Add a central module:
|
||||
- `src/shared/model-settings-compatibility.ts`
|
||||
|
||||
Core API:
|
||||
|
||||
```ts
|
||||
type DesiredModelSettings = {
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
type ModelSettingsCompatibilityInput = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
desired: DesiredModelSettings
|
||||
}
|
||||
|
||||
type ModelSettingsCompatibilityChange = {
|
||||
field: "variant" | "reasoningEffort"
|
||||
from: string
|
||||
to?: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
type ModelSettingsCompatibilityResult = {
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
changes: ModelSettingsCompatibilityChange[]
|
||||
}
|
||||
```
|
||||
|
||||
## Compatibility model
|
||||
|
||||
Phase 1 should be **metadata-first where the platform exposes reliable capability data**, and only fall back to family-based rules when that metadata is absent.
|
||||
|
||||
### Variant compatibility
|
||||
|
||||
Preferred source of truth:
|
||||
- OpenCode/provider model metadata (`variants`)
|
||||
|
||||
Fallback when metadata is unavailable:
|
||||
- family-based ladders
|
||||
|
||||
Examples of fallback ladders:
|
||||
- Claude Opus family: `low`, `medium`, `high`, `max`
|
||||
- Claude Sonnet/Haiku family: `low`, `medium`, `high`
|
||||
- OpenAI GPT family: conservative family fallback only when metadata is missing
|
||||
- Unknown family: drop unsupported values conservatively
|
||||
|
||||
### Reasoning effort compatibility
|
||||
|
||||
Current Phase 1 source of truth:
|
||||
- conservative model/provider family heuristics
|
||||
|
||||
Reason:
|
||||
- the currently available OpenCode SDK/provider metadata exposes model `variants`, but does not expose an equivalent per-model capability list for `reasoningEffort` levels
|
||||
|
||||
Examples:
|
||||
- GPT/OpenAI-style models: `low`, `medium`, `high`, `xhigh` where supported by family heuristics
|
||||
- Claude family via current OpenCode path: treat `reasoningEffort` as unsupported in Phase 1 and remove it
|
||||
|
||||
The resolver should remain pure model/settings logic only. Transport restrictions remain the responsibility of the request-building path.
|
||||
|
||||
## Separation of concerns
|
||||
|
||||
This design intentionally separates:
|
||||
- model selection (`resolveModel...`, fallback chains)
|
||||
- settings compatibility (this resolver)
|
||||
- request transport compatibility (`chat.params`, prompt body constraints)
|
||||
|
||||
That keeps responsibilities clear:
|
||||
- choose model first
|
||||
- normalize settings second
|
||||
- build request third
|
||||
|
||||
## First integration point
|
||||
|
||||
Phase 1 should first integrate into `chat.params`.
|
||||
|
||||
Why:
|
||||
- it is already the centralized path for request-time tuning
|
||||
- it can influence provider-facing options without leaking unsupported fields into prompt payload bodies
|
||||
- it avoids trying to patch every prompt constructor at once
|
||||
|
||||
## Rollout plan
|
||||
|
||||
### Phase 1
|
||||
- add resolver module and tests
|
||||
- integrate into `chat.params`
|
||||
- migrate `anthropic-effort` to either use the resolver or become a thin Claude-specific supplement around it
|
||||
|
||||
### Phase 2
|
||||
- expand to `thinking`, `maxTokens`, `temperature`, `top_p`
|
||||
- formalize request-path capability tables if needed
|
||||
|
||||
### Phase 3
|
||||
- centralize all variant/reasoning normalization away from scattered hooks and ad hoc callers
|
||||
|
||||
## Risks
|
||||
|
||||
- Overfitting family rules to current model naming conventions
|
||||
- Accidentally changing request semantics on paths that currently rely on implicit behavior
|
||||
- Mixing provider transport limitations with model capability logic
|
||||
|
||||
## Mitigations
|
||||
|
||||
- Keep resolver pure and narrowly scoped in Phase 1
|
||||
- Add explicit regression tests for keep/downgrade/drop decisions
|
||||
- Integrate at one central point first (`chat.params`)
|
||||
- Preserve existing behavior where desired values are already valid
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed with the central resolver as a new, isolated implementation in a dedicated branch/worktree.
|
||||
This is the clean long-term path and is more reviewable than continuing to add special-case clamps in hooks.
|
||||
@@ -25,10 +25,12 @@
|
||||
"build:all": "bun run build && bun run build:binaries",
|
||||
"build:binaries": "bun run script/build-binaries.ts",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
"build:model-capabilities": "bun run script/build-model-capabilities.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"prepare": "bun run build",
|
||||
"postinstall": "node postinstall.mjs",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
13
script/build-model-capabilities.ts
Normal file
13
script/build-model-capabilities.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { writeFileSync } from "fs"
|
||||
import { resolve } from "path"
|
||||
import {
|
||||
fetchModelCapabilitiesSnapshot,
|
||||
MODELS_DEV_SOURCE_URL,
|
||||
} from "../src/shared/model-capabilities-cache"
|
||||
|
||||
const OUTPUT_PATH = resolve(import.meta.dir, "../src/generated/model-capabilities.generated.json")
|
||||
|
||||
console.log(`Fetching model capabilities snapshot from ${MODELS_DEV_SOURCE_URL}...`)
|
||||
const snapshot = await fetchModelCapabilitiesSnapshot()
|
||||
writeFileSync(OUTPUT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`)
|
||||
console.log(`Generated ${OUTPUT_PATH} with ${Object.keys(snapshot.models).length} models`)
|
||||
@@ -2303,6 +2303,30 @@
|
||||
"created_at": "2026-03-23T04:28:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2758
|
||||
},
|
||||
{
|
||||
"name": "anas-asghar4831",
|
||||
"id": 110368394,
|
||||
"comment_id": 4128950310,
|
||||
"created_at": "2026-03-25T18:48:19Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2837
|
||||
},
|
||||
{
|
||||
"name": "clansty",
|
||||
"id": 18461360,
|
||||
"comment_id": 4129934858,
|
||||
"created_at": "2026-03-25T21:33:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2839
|
||||
},
|
||||
{
|
||||
"name": "ventsislav-georgiev",
|
||||
"id": 5616486,
|
||||
"comment_id": 4130417794,
|
||||
"created_at": "2026-03-25T23:11:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2840
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,8 +13,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
|
||||
| **Sisyphus** | claude-opus-4-6 max | 0.1 | all | k2p5 → kimi-k2.5 → gpt-5.4 medium → glm-5 → big-pickle | Main orchestrator, plans + delegates |
|
||||
| **Hephaestus** | gpt-5.3-codex medium | 0.1 | all | gpt-5.4 medium (copilot) | Autonomous deep worker |
|
||||
| **Oracle** | gpt-5.4 high | 0.1 | subagent | gemini-3.1-pro high → claude-opus-4-6 max | Read-only consultation |
|
||||
| **Librarian** | gemini-3-flash | 0.1 | subagent | minimax-m2.5-free → big-pickle | External docs/code search |
|
||||
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.5-free → claude-haiku-4-5 → gpt-5-nano | Contextual grep |
|
||||
| **Librarian** | minimax-m2.7 | 0.1 | subagent | minimax-m2.7-highspeed → claude-haiku-4-5 → gpt-5-nano | External docs/code search |
|
||||
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.7-highspeed → minimax-m2.7 → claude-haiku-4-5 → gpt-5-nano | Contextual grep |
|
||||
| **Multimodal-Looker** | gpt-5.3-codex medium | 0.1 | subagent | k2p5 → gemini-3-flash → glm-4.6v → gpt-5-nano | PDF/image analysis |
|
||||
| **Metis** | claude-opus-4-6 max | **0.3** | subagent | gpt-5.4 high → gemini-3.1-pro high | Pre-planning consultant |
|
||||
| **Momus** | gpt-5.4 xhigh | 0.1 | subagent | claude-opus-4-6 max → gemini-3.1-pro high | Plan reviewer |
|
||||
|
||||
16
src/agents/athena.ts
Normal file
16
src/agents/athena.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { buildAthenaPrompt, type AthenaPromptOptions } from "./athena/prompt"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export function createAthenaAgent(model: string, options?: AthenaPromptOptions): AgentConfig {
|
||||
return {
|
||||
description: "Primary council orchestrator for Athena workflows. (Athena - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
prompt: buildAthenaPrompt(options),
|
||||
}
|
||||
}
|
||||
createAthenaAgent.mode = MODE
|
||||
36
src/agents/athena/council-contract.ts
Normal file
36
src/agents/athena/council-contract.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const COUNCIL_MEMBER_RESPONSE_TAG = "COUNCIL_MEMBER_RESPONSE"
|
||||
|
||||
export type CouncilVerdict = "support" | "oppose" | "mixed" | "abstain"
|
||||
|
||||
export interface CouncilEvidenceItem {
|
||||
source: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface CouncilMemberResponse {
|
||||
member: string
|
||||
verdict: CouncilVerdict
|
||||
confidence: number
|
||||
rationale: string
|
||||
risks: string[]
|
||||
evidence: CouncilEvidenceItem[]
|
||||
proposed_actions: string[]
|
||||
missing_information: string[]
|
||||
}
|
||||
|
||||
export interface AthenaCouncilMember {
|
||||
name: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface ParsedCouncilMemberResponse {
|
||||
ok: true
|
||||
value: CouncilMemberResponse
|
||||
source: "raw_json" | "tagged_json"
|
||||
}
|
||||
|
||||
export interface CouncilResponseParseFailure {
|
||||
ok: false
|
||||
error: string
|
||||
source: "raw_json" | "tagged_json" | "none"
|
||||
}
|
||||
24
src/agents/athena/council-members.ts
Normal file
24
src/agents/athena/council-members.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AthenaCouncilMember } from "./council-contract"
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
export function toCouncilMemberAgentName(memberName: string): string {
|
||||
const slug = slugify(memberName)
|
||||
return `council-member-${slug || "member"}`
|
||||
}
|
||||
|
||||
export function buildCouncilRosterSection(members: AthenaCouncilMember[]): string {
|
||||
if (members.length === 0) {
|
||||
return "- No configured council roster. Use default subagent_type=\"council-member\"."
|
||||
}
|
||||
|
||||
return members
|
||||
.map((member) => `- ${member.name} | model=${member.model} | subagent_type=${toCouncilMemberAgentName(member.name)}`)
|
||||
.join("\n")
|
||||
}
|
||||
38
src/agents/athena/council-quorum.test.ts
Normal file
38
src/agents/athena/council-quorum.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { evaluateCouncilQuorum } from "./council-quorum"
|
||||
|
||||
describe("evaluateCouncilQuorum", () => {
|
||||
test("#given partial failures with enough successful members #when evaluating #then quorum reached with graceful degradation", () => {
|
||||
// given
|
||||
const input = {
|
||||
totalMembers: 5,
|
||||
successfulMembers: 3,
|
||||
failedMembers: 2,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = evaluateCouncilQuorum(input)
|
||||
|
||||
// then
|
||||
expect(result.required).toBe(3)
|
||||
expect(result.reached).toBe(true)
|
||||
expect(result.gracefulDegradation).toBe(true)
|
||||
})
|
||||
|
||||
test("#given too many failures #when evaluating #then quorum is unreachable", () => {
|
||||
// given
|
||||
const input = {
|
||||
totalMembers: 4,
|
||||
successfulMembers: 1,
|
||||
failedMembers: 3,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = evaluateCouncilQuorum(input)
|
||||
|
||||
// then
|
||||
expect(result.required).toBe(2)
|
||||
expect(result.reached).toBe(false)
|
||||
expect(result.canStillReach).toBe(false)
|
||||
})
|
||||
})
|
||||
36
src/agents/athena/council-quorum.ts
Normal file
36
src/agents/athena/council-quorum.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CouncilQuorumInput {
|
||||
totalMembers: number
|
||||
successfulMembers: number
|
||||
failedMembers: number
|
||||
requestedQuorum?: number
|
||||
}
|
||||
|
||||
export interface CouncilQuorumResult {
|
||||
required: number
|
||||
reached: boolean
|
||||
canStillReach: boolean
|
||||
gracefulDegradation: boolean
|
||||
}
|
||||
|
||||
function clampMinimumQuorum(totalMembers: number, requestedQuorum?: number): number {
|
||||
if (requestedQuorum && requestedQuorum > 0) {
|
||||
return Math.min(totalMembers, requestedQuorum)
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(totalMembers / 2))
|
||||
}
|
||||
|
||||
export function evaluateCouncilQuorum(input: CouncilQuorumInput): CouncilQuorumResult {
|
||||
const required = clampMinimumQuorum(input.totalMembers, input.requestedQuorum)
|
||||
const reached = input.successfulMembers >= required
|
||||
const remainingPossible = input.totalMembers - input.failedMembers
|
||||
const canStillReach = remainingPossible >= required
|
||||
const gracefulDegradation = reached && input.failedMembers > 0
|
||||
|
||||
return {
|
||||
required,
|
||||
reached,
|
||||
canStillReach,
|
||||
gracefulDegradation,
|
||||
}
|
||||
}
|
||||
71
src/agents/athena/council-response-parser.test.ts
Normal file
71
src/agents/athena/council-response-parser.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseCouncilMemberResponse } from "./council-response-parser"
|
||||
|
||||
describe("parseCouncilMemberResponse", () => {
|
||||
test("#given valid raw json #when parsing #then returns parsed council payload", () => {
|
||||
// given
|
||||
const raw = JSON.stringify({
|
||||
member: "architect",
|
||||
verdict: "support",
|
||||
confidence: 0.9,
|
||||
rationale: "Matches existing module boundaries",
|
||||
risks: ["Regression in edge-case parser"],
|
||||
evidence: [{ source: "src/agents/athena.ts", detail: "Current prompt is too generic" }],
|
||||
proposed_actions: ["Add strict orchestration workflow"],
|
||||
missing_information: ["Need runtime timeout budget"],
|
||||
})
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.source).toBe("raw_json")
|
||||
expect(result.value.member).toBe("architect")
|
||||
expect(result.value.verdict).toBe("support")
|
||||
})
|
||||
|
||||
test("#given tagged json payload #when parsing #then extracts from COUNCIL_MEMBER_RESPONSE tag", () => {
|
||||
// given
|
||||
const raw = [
|
||||
"analysis intro",
|
||||
"<COUNCIL_MEMBER_RESPONSE>",
|
||||
JSON.stringify({
|
||||
member: "skeptic",
|
||||
verdict: "mixed",
|
||||
confidence: 0.62,
|
||||
rationale: "Quorum logic exists but retry handling is weak",
|
||||
risks: ["Timeout blind spot"],
|
||||
evidence: [{ source: "src/tools/background-task/create-background-wait.ts", detail: "No nudge semantics" }],
|
||||
proposed_actions: ["Add stuck detection policy"],
|
||||
missing_information: [],
|
||||
}),
|
||||
"</COUNCIL_MEMBER_RESPONSE>",
|
||||
].join("\n")
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.source).toBe("tagged_json")
|
||||
expect(result.value.member).toBe("skeptic")
|
||||
expect(result.value.proposed_actions).toEqual(["Add stuck detection policy"])
|
||||
})
|
||||
|
||||
test("#given malformed payload #when parsing #then returns structured parse failure", () => {
|
||||
// given
|
||||
const raw = "Council says: maybe this works"
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.source).toBe("none")
|
||||
expect(result.error.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
159
src/agents/athena/council-response-parser.ts
Normal file
159
src/agents/athena/council-response-parser.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
COUNCIL_MEMBER_RESPONSE_TAG,
|
||||
type CouncilMemberResponse,
|
||||
type CouncilResponseParseFailure,
|
||||
type ParsedCouncilMemberResponse,
|
||||
} from "./council-contract"
|
||||
|
||||
type ParseResult = ParsedCouncilMemberResponse | CouncilResponseParseFailure
|
||||
|
||||
function normalizeJsonPayload(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const firstNewLine = trimmed.indexOf("\n")
|
||||
if (firstNewLine < 0) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return trimmed.slice(firstNewLine + 1, -3).trim()
|
||||
}
|
||||
|
||||
function tryParseJsonObject(input: string): unknown {
|
||||
const normalized = normalizeJsonPayload(input)
|
||||
if (!normalized.startsWith("{")) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractTaggedPayload(raw: string): string | null {
|
||||
const xmlLike = new RegExp(
|
||||
`<${COUNCIL_MEMBER_RESPONSE_TAG}>([\\s\\S]*?)<\\/${COUNCIL_MEMBER_RESPONSE_TAG}>`,
|
||||
"i",
|
||||
)
|
||||
const xmlMatch = raw.match(xmlLike)
|
||||
if (xmlMatch?.[1]) {
|
||||
return xmlMatch[1].trim()
|
||||
}
|
||||
|
||||
const prefixed = new RegExp(`${COUNCIL_MEMBER_RESPONSE_TAG}\\s*:\\s*`, "i")
|
||||
const prefixMatch = raw.match(prefixed)
|
||||
if (!prefixMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchIndex = prefixMatch.index
|
||||
if (matchIndex === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rest = raw.slice(matchIndex + prefixMatch[0].length)
|
||||
const firstBrace = rest.indexOf("{")
|
||||
if (firstBrace < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return rest.slice(firstBrace).trim()
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function isEvidenceArray(value: unknown): value is CouncilMemberResponse["evidence"] {
|
||||
return Array.isArray(value)
|
||||
&& value.every(
|
||||
(item) =>
|
||||
typeof item === "object"
|
||||
&& item !== null
|
||||
&& typeof (item as { source?: unknown }).source === "string"
|
||||
&& typeof (item as { detail?: unknown }).detail === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function validateCouncilMemberResponse(payload: unknown): CouncilMemberResponse | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = payload as Record<string, unknown>
|
||||
const verdict = candidate.verdict
|
||||
const confidence = candidate.confidence
|
||||
|
||||
if (
|
||||
typeof candidate.member !== "string"
|
||||
|| (verdict !== "support" && verdict !== "oppose" && verdict !== "mixed" && verdict !== "abstain")
|
||||
|| typeof confidence !== "number"
|
||||
|| confidence < 0
|
||||
|| confidence > 1
|
||||
|| typeof candidate.rationale !== "string"
|
||||
|| !isStringArray(candidate.risks)
|
||||
|| !isEvidenceArray(candidate.evidence)
|
||||
|| !isStringArray(candidate.proposed_actions)
|
||||
|| !isStringArray(candidate.missing_information)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
member: candidate.member,
|
||||
verdict,
|
||||
confidence,
|
||||
rationale: candidate.rationale,
|
||||
risks: candidate.risks,
|
||||
evidence: candidate.evidence,
|
||||
proposed_actions: candidate.proposed_actions,
|
||||
missing_information: candidate.missing_information,
|
||||
}
|
||||
}
|
||||
|
||||
function parseValidated(payload: unknown, source: ParsedCouncilMemberResponse["source"]): ParseResult {
|
||||
const validated = validateCouncilMemberResponse(payload)
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Council member response does not match required contract",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: validated,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCouncilMemberResponse(raw: string): ParseResult {
|
||||
const directJson = tryParseJsonObject(raw)
|
||||
if (directJson) {
|
||||
return parseValidated(directJson, "raw_json")
|
||||
}
|
||||
|
||||
const taggedPayload = extractTaggedPayload(raw)
|
||||
if (taggedPayload) {
|
||||
const taggedJson = tryParseJsonObject(taggedPayload)
|
||||
if (taggedJson) {
|
||||
return parseValidated(taggedJson, "tagged_json")
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "Tagged council response found, but JSON payload is invalid",
|
||||
source: "tagged_json",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: "No parseable council response payload found",
|
||||
source: "none",
|
||||
}
|
||||
}
|
||||
50
src/agents/athena/council-retry.test.ts
Normal file
50
src/agents/athena/council-retry.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decideCouncilRecoveryAction } from "./council-retry"
|
||||
|
||||
describe("decideCouncilRecoveryAction", () => {
|
||||
test("#given running member with stale progress and nudge budget #when deciding #then nudge", () => {
|
||||
// given
|
||||
const now = 10_000
|
||||
const decision = decideCouncilRecoveryAction(
|
||||
{
|
||||
status: "running",
|
||||
attempts: 1,
|
||||
nudges: 0,
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
},
|
||||
{
|
||||
maxAttempts: 2,
|
||||
maxNudges: 1,
|
||||
stuckAfterMs: 2_000,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(decision.action).toBe("nudge")
|
||||
})
|
||||
|
||||
test("#given stuck member after nudge with retry budget #when deciding #then retry", () => {
|
||||
// given
|
||||
const now = 20_000
|
||||
const decision = decideCouncilRecoveryAction(
|
||||
{
|
||||
status: "running",
|
||||
attempts: 1,
|
||||
nudges: 1,
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
maxNudges: 1,
|
||||
stuckAfterMs: 5_000,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(decision.action).toBe("retry")
|
||||
})
|
||||
})
|
||||
68
src/agents/athena/council-retry.ts
Normal file
68
src/agents/athena/council-retry.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type CouncilMemberTaskStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out"
|
||||
|
||||
export interface CouncilMemberTaskState {
|
||||
status: CouncilMemberTaskStatus
|
||||
attempts: number
|
||||
nudges: number
|
||||
startedAt: number
|
||||
lastProgressAt: number
|
||||
}
|
||||
|
||||
export interface CouncilRetryPolicy {
|
||||
maxAttempts: number
|
||||
maxNudges: number
|
||||
stuckAfterMs: number
|
||||
}
|
||||
|
||||
export type CouncilRecoveryAction = "wait" | "nudge" | "retry" | "give_up"
|
||||
|
||||
export interface CouncilRecoveryDecision {
|
||||
action: CouncilRecoveryAction
|
||||
reason: string
|
||||
}
|
||||
|
||||
export function isCouncilMemberStuck(
|
||||
now: number,
|
||||
lastProgressAt: number,
|
||||
stuckAfterMs: number,
|
||||
): boolean {
|
||||
return now - lastProgressAt >= stuckAfterMs
|
||||
}
|
||||
|
||||
export function decideCouncilRecoveryAction(
|
||||
state: CouncilMemberTaskState,
|
||||
policy: CouncilRetryPolicy,
|
||||
now: number,
|
||||
): CouncilRecoveryDecision {
|
||||
if (state.status === "completed" || state.status === "cancelled") {
|
||||
return { action: "give_up", reason: "Task already reached terminal status" }
|
||||
}
|
||||
|
||||
if (state.status === "failed" || state.status === "timed_out") {
|
||||
if (state.attempts < policy.maxAttempts) {
|
||||
return { action: "retry", reason: "Terminal failure with retries remaining" }
|
||||
}
|
||||
return { action: "give_up", reason: "Terminal failure and retry budget exhausted" }
|
||||
}
|
||||
|
||||
const stuck = isCouncilMemberStuck(now, state.lastProgressAt, policy.stuckAfterMs)
|
||||
if (!stuck) {
|
||||
return { action: "wait", reason: "Task is still making progress" }
|
||||
}
|
||||
|
||||
if (state.nudges < policy.maxNudges) {
|
||||
return { action: "nudge", reason: "Task appears stuck and nudge budget remains" }
|
||||
}
|
||||
|
||||
if (state.attempts < policy.maxAttempts) {
|
||||
return { action: "retry", reason: "Task stuck after nudges, retrying with fresh run" }
|
||||
}
|
||||
|
||||
return { action: "give_up", reason: "Task stuck and all recovery budgets exhausted" }
|
||||
}
|
||||
43
src/agents/athena/council-synthesis.test.ts
Normal file
43
src/agents/athena/council-synthesis.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { synthesizeCouncilOutcome } from "./council-synthesis"
|
||||
import type { CouncilMemberResponse } from "./council-contract"
|
||||
|
||||
function response(overrides: Partial<CouncilMemberResponse>): CouncilMemberResponse {
|
||||
return {
|
||||
member: "member-a",
|
||||
verdict: "support",
|
||||
confidence: 0.8,
|
||||
rationale: "default rationale",
|
||||
risks: [],
|
||||
evidence: [{ source: "file.ts", detail: "detail" }],
|
||||
proposed_actions: ["Ship with tests"],
|
||||
missing_information: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("synthesizeCouncilOutcome", () => {
|
||||
test("#given majority support with one failure #when synthesizing #then reports agreement and graceful degradation", () => {
|
||||
// given
|
||||
const responses = [
|
||||
response({ member: "architect", verdict: "support", proposed_actions: ["Ship with tests"] }),
|
||||
response({ member: "skeptic", verdict: "support", proposed_actions: ["Ship with tests"] }),
|
||||
response({ member: "critic", verdict: "oppose", risks: ["Parser drift"] }),
|
||||
]
|
||||
|
||||
// when
|
||||
const result = synthesizeCouncilOutcome({
|
||||
responses,
|
||||
failedMembers: ["perf"],
|
||||
quorumReached: true,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.majorityVerdict).toBe("support")
|
||||
expect(result.agreementMembers).toEqual(["architect", "skeptic"])
|
||||
expect(result.disagreementMembers).toContain("critic")
|
||||
expect(result.disagreementMembers).toContain("perf")
|
||||
expect(result.commonActions).toEqual(["Ship with tests"])
|
||||
expect(result.gracefulDegradation).toBe(true)
|
||||
})
|
||||
})
|
||||
141
src/agents/athena/council-synthesis.ts
Normal file
141
src/agents/athena/council-synthesis.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { CouncilMemberResponse, CouncilVerdict } from "./council-contract"
|
||||
|
||||
export interface CouncilSynthesisInput {
|
||||
responses: CouncilMemberResponse[]
|
||||
failedMembers: string[]
|
||||
quorumReached: boolean
|
||||
}
|
||||
|
||||
export interface CouncilSynthesisResult {
|
||||
majorityVerdict: CouncilVerdict
|
||||
consensusLevel: "unanimous" | "strong" | "split" | "fragmented"
|
||||
agreementMembers: string[]
|
||||
disagreementMembers: string[]
|
||||
commonActions: string[]
|
||||
contestedRisks: string[]
|
||||
unresolvedQuestions: string[]
|
||||
gracefulDegradation: boolean
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getMajorityVerdict(responses: CouncilMemberResponse[]): CouncilVerdict {
|
||||
const counts = new Map<CouncilVerdict, number>()
|
||||
for (const response of responses) {
|
||||
counts.set(response.verdict, (counts.get(response.verdict) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const orderedVerdicts: CouncilVerdict[] = ["support", "mixed", "oppose", "abstain"]
|
||||
let winner: CouncilVerdict = "abstain"
|
||||
let winnerCount = -1
|
||||
|
||||
for (const verdict of orderedVerdicts) {
|
||||
const count = counts.get(verdict) ?? 0
|
||||
if (count > winnerCount) {
|
||||
winner = verdict
|
||||
winnerCount = count
|
||||
}
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
|
||||
function deriveConsensusLevel(agreementCount: number, totalCount: number): CouncilSynthesisResult["consensusLevel"] {
|
||||
if (totalCount === 0) {
|
||||
return "fragmented"
|
||||
}
|
||||
|
||||
if (agreementCount === totalCount) {
|
||||
return "unanimous"
|
||||
}
|
||||
|
||||
const ratio = agreementCount / totalCount
|
||||
if (ratio >= 0.75) {
|
||||
return "strong"
|
||||
}
|
||||
if (ratio >= 0.5) {
|
||||
return "split"
|
||||
}
|
||||
return "fragmented"
|
||||
}
|
||||
|
||||
function collectCommonActions(responses: CouncilMemberResponse[]): string[] {
|
||||
const counts = new Map<string, { text: string; count: number }>()
|
||||
for (const response of responses) {
|
||||
for (const action of response.proposed_actions) {
|
||||
const key = normalizeKey(action)
|
||||
const existing = counts.get(key)
|
||||
if (!existing) {
|
||||
counts.set(key, { text: action, count: 1 })
|
||||
continue
|
||||
}
|
||||
existing.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
const threshold = Math.max(2, Math.ceil(responses.length / 2))
|
||||
return [...counts.values()]
|
||||
.filter((item) => item.count >= threshold)
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
function collectContestedRisks(responses: CouncilMemberResponse[]): string[] {
|
||||
const counts = new Map<string, { text: string; count: number }>()
|
||||
for (const response of responses) {
|
||||
for (const risk of response.risks) {
|
||||
const key = normalizeKey(risk)
|
||||
const existing = counts.get(key)
|
||||
if (!existing) {
|
||||
counts.set(key, { text: risk, count: 1 })
|
||||
continue
|
||||
}
|
||||
existing.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.values()]
|
||||
.filter((item) => item.count === 1)
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
function collectUnresolvedQuestions(responses: CouncilMemberResponse[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const questions: string[] = []
|
||||
|
||||
for (const response of responses) {
|
||||
for (const question of response.missing_information) {
|
||||
const key = normalizeKey(question)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
questions.push(question)
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
export function synthesizeCouncilOutcome(input: CouncilSynthesisInput): CouncilSynthesisResult {
|
||||
const majorityVerdict = getMajorityVerdict(input.responses)
|
||||
const agreementMembers = input.responses
|
||||
.filter((response) => response.verdict === majorityVerdict)
|
||||
.map((response) => response.member)
|
||||
const disagreementMembers = input.responses
|
||||
.filter((response) => response.verdict !== majorityVerdict)
|
||||
.map((response) => response.member)
|
||||
.concat(input.failedMembers)
|
||||
|
||||
return {
|
||||
majorityVerdict,
|
||||
consensusLevel: deriveConsensusLevel(agreementMembers.length, input.responses.length),
|
||||
agreementMembers,
|
||||
disagreementMembers,
|
||||
commonActions: collectCommonActions(input.responses),
|
||||
contestedRisks: collectContestedRisks(input.responses),
|
||||
unresolvedQuestions: collectUnresolvedQuestions(input.responses),
|
||||
gracefulDegradation: input.quorumReached && input.failedMembers.length > 0,
|
||||
}
|
||||
}
|
||||
68
src/agents/athena/prompt.ts
Normal file
68
src/agents/athena/prompt.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { AthenaCouncilMember } from "./council-contract"
|
||||
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./council-contract"
|
||||
import { buildCouncilRosterSection } from "./council-members"
|
||||
|
||||
export interface AthenaPromptOptions {
|
||||
members?: AthenaCouncilMember[]
|
||||
}
|
||||
|
||||
export function buildAthenaPrompt(options: AthenaPromptOptions = {}): string {
|
||||
const roster = buildCouncilRosterSection(options.members ?? [])
|
||||
|
||||
return `You are Athena, a primary council orchestrator agent.
|
||||
|
||||
Operate as a strict multi-model council coordinator.
|
||||
|
||||
Core workflow:
|
||||
1) Receive user request and define a concise decision question for the council.
|
||||
2) Fan out council-member tasks in parallel with task(..., run_in_background=true).
|
||||
3) Collect with background_wait first, then background_output for completed IDs.
|
||||
4) Parse each member output as strict JSON contract; fallback to ${COUNCIL_MEMBER_RESPONSE_TAG} tag extraction.
|
||||
5) Apply quorum, retries, and graceful degradation.
|
||||
6) Synthesize agreement vs disagreement explicitly, then provide final recommendation.
|
||||
|
||||
Council roster:
|
||||
${roster}
|
||||
|
||||
Execution protocol:
|
||||
- Always run council fan-out in parallel. Never sequentially wait on one member before launching others.
|
||||
- Use subagent_type="council-member" if no named roster is configured.
|
||||
- For named roster entries, use that exact subagent_type so each member runs on its assigned model.
|
||||
- Keep prompts evidence-oriented and read-only. Members must inspect code, tests, logs, and config references.
|
||||
- Never ask members to edit files, delegate, or switch agents.
|
||||
|
||||
Member response contract (required):
|
||||
- Preferred: raw JSON only.
|
||||
- Fallback allowed: wrap JSON in <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
|
||||
- Required JSON keys:
|
||||
{
|
||||
"member": string,
|
||||
"verdict": "support" | "oppose" | "mixed" | "abstain",
|
||||
"confidence": number (0..1),
|
||||
"rationale": string,
|
||||
"risks": string[],
|
||||
"evidence": [{ "source": string, "detail": string }],
|
||||
"proposed_actions": string[],
|
||||
"missing_information": string[]
|
||||
}
|
||||
|
||||
Failure and stuck handling:
|
||||
- Track per-member attempts, nudges, and progress timestamps.
|
||||
- Detect stuck tasks when no progress appears within expected interval.
|
||||
- First recovery action for stuck: nudge through continuation prompt.
|
||||
- If still stuck or failed: retry with a fresh background task, bounded by retry limit.
|
||||
- If a member remains failed after retry budget, mark as failed and continue.
|
||||
|
||||
Quorum and degradation:
|
||||
- Default quorum: ceil(total_members / 2), minimum 1.
|
||||
- If quorum reached, continue synthesis even when some members failed.
|
||||
- If quorum cannot be reached after retries, report partial findings and explicit uncertainty.
|
||||
|
||||
Synthesis output requirements:
|
||||
- Separate "agreement" and "disagreement" sections.
|
||||
- Name which members support the majority view and which dissent or failed.
|
||||
- Call out unresolved questions and evidence gaps.
|
||||
- End with one executable recommendation and a confidence statement.
|
||||
|
||||
Do not expose internal operational noise. Report concise structured findings.`
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { createAthenaAgent } from "./athena"
|
||||
import { createCouncilMemberAgent } from "./council-member"
|
||||
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import {
|
||||
@@ -33,6 +35,7 @@ type AgentSource = AgentFactory | AgentConfig
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
sisyphus: createSisyphusAgent,
|
||||
hephaestus: createHephaestusAgent,
|
||||
athena: createAthenaAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
@@ -43,6 +46,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as AgentFactory,
|
||||
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
|
||||
"council-member": createCouncilMemberAgent,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,10 @@ export function mergeAgentConfig(
|
||||
const { prompt_append, ...rest } = migratedOverride
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (merged.prompt && typeof merged.prompt === 'string' && merged.prompt.startsWith('file://')) {
|
||||
merged.prompt = resolvePromptAppend(merged.prompt, directory)
|
||||
}
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append, directory)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildAgent, isFactory } from "../agent-builder"
|
||||
import { applyOverrides } from "./agent-overrides"
|
||||
import { applyEnvironmentContext } from "./environment-context"
|
||||
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function collectPendingBuiltinAgents(input: {
|
||||
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
|
||||
@@ -38,7 +39,6 @@ export function collectPendingBuiltinAgents(input: {
|
||||
browserProvider,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
isFirstRunNoCache,
|
||||
disabledSkills,
|
||||
disableOmoEnv = false,
|
||||
} = input
|
||||
@@ -55,8 +55,9 @@ export function collectPendingBuiltinAgents(input: {
|
||||
if (agentName === "sisyphus-junior") continue
|
||||
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
|
||||
|
||||
const override = agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
const override = Object.entries(agentOverrides).find(
|
||||
([key]) => key.toLowerCase() === agentName.toLowerCase(),
|
||||
)?.[1]
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
// Check if agent requires a specific model
|
||||
@@ -75,7 +76,13 @@ export function collectPendingBuiltinAgents(input: {
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolution && isFirstRunNoCache && !override?.model) {
|
||||
if (!resolution) {
|
||||
if (override?.model) {
|
||||
log("[agent-registration] User-configured model could not be resolved, falling back", {
|
||||
agent: agentName,
|
||||
configuredModel: override.model,
|
||||
})
|
||||
}
|
||||
resolution = getFirstFallbackModel(requirement)
|
||||
}
|
||||
if (!resolution) continue
|
||||
|
||||
51
src/agents/council-member.ts
Normal file
51
src/agents/council-member.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./athena/council-contract"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
const councilMemberRestrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"task",
|
||||
"task_*",
|
||||
"teammate",
|
||||
"call_omo_agent",
|
||||
"switch_agent",
|
||||
])
|
||||
|
||||
export function createCouncilMemberAgent(model: string): AgentConfig {
|
||||
return {
|
||||
description: "Internal hidden council member used by Athena. Read-only analysis only.",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
hidden: true,
|
||||
...councilMemberRestrictions,
|
||||
prompt: `You are an internal council-member for Athena.
|
||||
|
||||
You are strictly read-only and evidence-oriented.
|
||||
You must not modify files, delegate, or switch agents.
|
||||
You must cite concrete evidence from files, tests, logs, or tool output.
|
||||
|
||||
Output contract:
|
||||
- Preferred output: raw JSON only.
|
||||
- Fallback output: wrap JSON with <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
|
||||
- Required JSON schema:
|
||||
{
|
||||
"member": string,
|
||||
"verdict": "support" | "oppose" | "mixed" | "abstain",
|
||||
"confidence": number (0..1),
|
||||
"rationale": string,
|
||||
"risks": string[],
|
||||
"evidence": [{ "source": string, "detail": string }],
|
||||
"proposed_actions": string[],
|
||||
"missing_information": string[]
|
||||
}
|
||||
|
||||
Do not include markdown explanations outside the contract unless Athena asks for it explicitly.`,
|
||||
}
|
||||
}
|
||||
createCouncilMemberAgent.mode = MODE
|
||||
@@ -35,6 +35,11 @@ Task NOT complete without:
|
||||
- ${verificationText}
|
||||
</Verification>
|
||||
|
||||
<Termination>
|
||||
STOP after first successful verification. Do NOT re-verify.
|
||||
Maximum status checks: 2. Then stop regardless.
|
||||
</Termination>
|
||||
|
||||
<Style>
|
||||
- Start immediately. No acknowledgments.
|
||||
- Match user's communication style.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isGptModel, isGeminiModel, isGpt5_4Model } from "./types";
|
||||
import { isGptModel, isGeminiModel, isGpt5_4Model, isMiniMaxModel } from "./types";
|
||||
|
||||
describe("isGpt5_4Model", () => {
|
||||
test("detects gpt-5.4 models", () => {
|
||||
@@ -79,6 +79,28 @@ describe("isGptModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMiniMaxModel", () => {
|
||||
test("detects minimax models with provider prefix", () => {
|
||||
expect(isMiniMaxModel("opencode-go/minimax-m2.7")).toBe(true);
|
||||
expect(isMiniMaxModel("opencode/minimax-m2.7-highspeed")).toBe(true);
|
||||
expect(isMiniMaxModel("opencode-go/minimax-m2.5")).toBe(true);
|
||||
expect(isMiniMaxModel("opencode/minimax-m2.5-free")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects minimax models without provider prefix", () => {
|
||||
expect(isMiniMaxModel("minimax-m2.7")).toBe(true);
|
||||
expect(isMiniMaxModel("minimax-m2.7-highspeed")).toBe(true);
|
||||
expect(isMiniMaxModel("minimax-m2.5")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match non-minimax models", () => {
|
||||
expect(isMiniMaxModel("openai/gpt-5.4")).toBe(false);
|
||||
expect(isMiniMaxModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isMiniMaxModel("google/gemini-3.1-pro")).toBe(false);
|
||||
expect(isMiniMaxModel("opencode-go/kimi-k2.5")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGeminiModel", () => {
|
||||
test("#given google provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true);
|
||||
|
||||
@@ -91,6 +91,11 @@ export function isGpt5_3CodexModel(model: string): boolean {
|
||||
|
||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"];
|
||||
|
||||
export function isMiniMaxModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
return modelName.includes("minimax");
|
||||
}
|
||||
|
||||
export function isGeminiModel(model: string): boolean {
|
||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true;
|
||||
|
||||
@@ -107,6 +112,7 @@ export function isGeminiModel(model: string): boolean {
|
||||
export type BuiltinAgentName =
|
||||
| "sisyphus"
|
||||
| "hephaestus"
|
||||
| "athena"
|
||||
| "oracle"
|
||||
| "librarian"
|
||||
| "explore"
|
||||
@@ -114,16 +120,17 @@ export type BuiltinAgentName =
|
||||
| "metis"
|
||||
| "momus"
|
||||
| "atlas"
|
||||
| "sisyphus-junior";
|
||||
| "sisyphus-junior"
|
||||
| "council-member";
|
||||
|
||||
export type OverridableAgentName = "build" | BuiltinAgentName;
|
||||
export type OverridableAgentName = "build" | Exclude<BuiltinAgentName, "council-member">;
|
||||
|
||||
export type AgentName = BuiltinAgentName;
|
||||
|
||||
export type AgentOverrideConfig = Partial<AgentConfig> & {
|
||||
prompt_append?: string;
|
||||
variant?: string;
|
||||
fallback_models?: string | string[];
|
||||
fallback_models?: string | (string | import("../config/schema/fallback-models").FallbackModelObject)[];
|
||||
};
|
||||
|
||||
export type AgentOverrides = Partial<
|
||||
|
||||
@@ -11,6 +11,32 @@ import * as shared from "../shared"
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
|
||||
|
||||
describe("createBuiltinAgents with model overrides", () => {
|
||||
test("registers athena as builtin primary agent", async () => {
|
||||
// #given
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.athena).toBeDefined()
|
||||
expect(agents.athena.mode).toBe("primary")
|
||||
})
|
||||
|
||||
test("registers council-member as hidden internal subagent", async () => {
|
||||
// #given
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents["council-member"]).toBeDefined()
|
||||
expect(agents["council-member"].mode).toBe("subagent")
|
||||
expect((agents["council-member"] as AgentConfig & { hidden?: boolean }).hidden).toBe(true)
|
||||
expect(agents.sisyphus.prompt).not.toContain("council-member")
|
||||
expect(agents.hephaestus.prompt).not.toContain("council-member")
|
||||
expect(agents.atlas.prompt).not.toContain("council-member")
|
||||
})
|
||||
|
||||
test("Sisyphus with default model has thinking config when all models available", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
|
||||
@@ -4,9 +4,15 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -68,9 +74,15 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -130,9 +142,15 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -193,10 +211,18 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
@@ -278,10 +304,18 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
@@ -363,9 +397,15 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -423,9 +463,15 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -483,9 +529,16 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -558,9 +611,16 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -634,9 +694,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -709,9 +776,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -785,9 +859,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -855,9 +936,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -926,9 +1014,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -984,9 +1078,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -1042,9 +1142,16 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1117,9 +1224,16 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -1192,9 +1306,15 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1256,9 +1376,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1322,9 +1448,16 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1400,9 +1533,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1478,9 +1618,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("runCliInstaller", () => {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
|
||||
@@ -56,6 +57,7 @@ describe("runCliInstaller", () => {
|
||||
opencodeZen: "no",
|
||||
zaiCodingPlan: "no",
|
||||
kimiForCoding: "no",
|
||||
opencodeGo: "no",
|
||||
}
|
||||
|
||||
//#when
|
||||
|
||||
@@ -3,6 +3,7 @@ import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
@@ -176,6 +177,21 @@ Examples:
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("refresh-model-capabilities")
|
||||
.description("Refresh the cached models.dev-based model capabilities snapshot")
|
||||
.option("-d, --directory <path>", "Working directory to read oh-my-opencode config from")
|
||||
.option("--source-url <url>", "Override the models.dev source URL")
|
||||
.option("--json", "Output refresh summary as JSON")
|
||||
.action(async (options) => {
|
||||
const exitCode = await refreshModelCapabilities({
|
||||
directory: options.directory,
|
||||
sourceUrl: options.sourceUrl,
|
||||
json: options.json ?? false,
|
||||
})
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show version information")
|
||||
|
||||
129
src/cli/config-manager/generate-athena-config.ts
Normal file
129
src/cli/config-manager/generate-athena-config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
import { toProviderAvailability } from "../provider-availability"
|
||||
import type { InstallConfig } from "../types"
|
||||
|
||||
export interface AthenaMemberTemplate {
|
||||
provider: string
|
||||
model: string
|
||||
name: string
|
||||
isAvailable: (config: InstallConfig) => boolean
|
||||
}
|
||||
|
||||
export interface AthenaCouncilMember {
|
||||
name: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AthenaConfig {
|
||||
model?: string
|
||||
members: AthenaCouncilMember[]
|
||||
}
|
||||
|
||||
const ATHENA_MEMBER_TEMPLATES: AthenaMemberTemplate[] = [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
name: "OpenAI Strategist",
|
||||
isAvailable: (config) => config.hasOpenAI,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
name: "Claude Strategist",
|
||||
isAvailable: (config) => config.hasClaude,
|
||||
},
|
||||
{
|
||||
provider: "google",
|
||||
model: "gemini-3.1-pro",
|
||||
name: "Gemini Strategist",
|
||||
isAvailable: (config) => config.hasGemini,
|
||||
},
|
||||
{
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4",
|
||||
name: "Copilot Strategist",
|
||||
isAvailable: (config) => config.hasCopilot,
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
model: "gpt-5.4",
|
||||
name: "OpenCode Strategist",
|
||||
isAvailable: (config) => config.hasOpencodeZen,
|
||||
},
|
||||
{
|
||||
provider: "zai-coding-plan",
|
||||
model: "glm-4.7",
|
||||
name: "Z.ai Strategist",
|
||||
isAvailable: (config) => config.hasZaiCodingPlan,
|
||||
},
|
||||
{
|
||||
provider: "kimi-for-coding",
|
||||
model: "k2p5",
|
||||
name: "Kimi Strategist",
|
||||
isAvailable: (config) => config.hasKimiForCoding,
|
||||
},
|
||||
{
|
||||
provider: "opencode-go",
|
||||
model: "glm-5",
|
||||
name: "OpenCode Go Strategist",
|
||||
isAvailable: (config) => config.hasOpencodeGo,
|
||||
},
|
||||
]
|
||||
|
||||
function toProviderModel(provider: string, model: string): string {
|
||||
const transformedModel = transformModelForProvider(provider, model)
|
||||
return `${provider}/${transformedModel}`
|
||||
}
|
||||
|
||||
function createUniqueMemberName(baseName: string, usedNames: Set<string>): string {
|
||||
if (!usedNames.has(baseName.toLowerCase())) {
|
||||
usedNames.add(baseName.toLowerCase())
|
||||
return baseName
|
||||
}
|
||||
|
||||
let suffix = 2
|
||||
let candidate = `${baseName} ${suffix}`
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
suffix += 1
|
||||
candidate = `${baseName} ${suffix}`
|
||||
}
|
||||
|
||||
usedNames.add(candidate.toLowerCase())
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function createAthenaCouncilMembersFromTemplates(
|
||||
templates: AthenaMemberTemplate[]
|
||||
): AthenaCouncilMember[] {
|
||||
const members: AthenaCouncilMember[] = []
|
||||
const usedNames = new Set<string>()
|
||||
|
||||
for (const template of templates) {
|
||||
members.push({
|
||||
name: createUniqueMemberName(template.name, usedNames),
|
||||
model: toProviderModel(template.provider, template.model),
|
||||
})
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
export function generateAthenaConfig(config: InstallConfig): AthenaConfig | undefined {
|
||||
const selectedTemplates = ATHENA_MEMBER_TEMPLATES.filter((template) => template.isAvailable(config))
|
||||
if (selectedTemplates.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const members = createAthenaCouncilMembersFromTemplates(selectedTemplates)
|
||||
const availability = toProviderAvailability(config)
|
||||
|
||||
const preferredCoordinator =
|
||||
(availability.native.openai && members.find((member) => member.model.startsWith("openai/"))) ||
|
||||
(availability.native.claude && members.find((member) => member.model.startsWith("anthropic/"))) ||
|
||||
members[0]
|
||||
|
||||
return {
|
||||
model: preferredCoordinator.model,
|
||||
members,
|
||||
}
|
||||
}
|
||||
102
src/cli/config-manager/generate-omo-config.test.ts
Normal file
102
src/cli/config-manager/generate-omo-config.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { InstallConfig } from "../types"
|
||||
import {
|
||||
createAthenaCouncilMembersFromTemplates,
|
||||
generateAthenaConfig,
|
||||
type AthenaMemberTemplate,
|
||||
} from "./generate-athena-config"
|
||||
import { generateOmoConfig } from "./generate-omo-config"
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
|
||||
function createInstallConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
|
||||
return {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("generateOmoConfig athena council", () => {
|
||||
it("creates athena council members from enabled providers", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig({ hasOpenAI: true, hasClaude: true, hasGemini: true })
|
||||
|
||||
// when
|
||||
const generated = generateOmoConfig(installConfig)
|
||||
const athena = generated.athena as { model?: string; members?: Array<{ name: string; model: string }> }
|
||||
const googleModel = `google/${transformModelForProvider("google", "gemini-3.1-pro")}`
|
||||
|
||||
// then
|
||||
expect(athena.model).toBe("openai/gpt-5.4")
|
||||
expect(athena.members).toHaveLength(3)
|
||||
expect(athena.members?.map((member) => member.model)).toEqual([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
googleModel,
|
||||
])
|
||||
})
|
||||
|
||||
it("does not create athena config when no providers are enabled", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig()
|
||||
|
||||
// when
|
||||
const generated = generateOmoConfig(installConfig)
|
||||
|
||||
// then
|
||||
expect(generated.athena).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateAthenaConfig", () => {
|
||||
it("uses anthropic as coordinator when openai is unavailable", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig({ hasClaude: true, hasCopilot: true })
|
||||
|
||||
// when
|
||||
const athena = generateAthenaConfig(installConfig)
|
||||
|
||||
// then
|
||||
expect(athena?.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(athena?.members?.map((member) => member.model)).toEqual([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"github-copilot/gpt-5.4",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAthenaCouncilMembersFromTemplates", () => {
|
||||
it("adds numeric suffixes when template names collide case-insensitively", () => {
|
||||
// given
|
||||
const templates: AthenaMemberTemplate[] = [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
name: "Strategist",
|
||||
isAvailable: () => true,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
name: "strategist",
|
||||
isAvailable: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
const members = createAthenaCouncilMembersFromTemplates(templates)
|
||||
|
||||
// then
|
||||
expect(members).toEqual([
|
||||
{ name: "Strategist", model: "openai/gpt-5.4" },
|
||||
{ name: "strategist 2", model: "anthropic/claude-sonnet-4-6" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { InstallConfig } from "../types"
|
||||
import { generateModelConfig } from "../model-fallback"
|
||||
import { generateAthenaConfig } from "./generate-athena-config"
|
||||
|
||||
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
|
||||
return generateModelConfig(installConfig)
|
||||
const generatedConfig = generateModelConfig(installConfig)
|
||||
const athenaConfig = generateAthenaConfig(installConfig)
|
||||
|
||||
if (!athenaConfig) {
|
||||
return generatedConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...generatedConfig,
|
||||
athena: athenaConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -4,6 +4,10 @@ import { getOpenCodeCacheDir } from "../../../shared"
|
||||
import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
|
||||
import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant"
|
||||
|
||||
function formatCapabilityResolutionLabel(mode: string | undefined): string {
|
||||
return mode ?? "unknown"
|
||||
}
|
||||
|
||||
export function buildModelResolutionDetails(options: {
|
||||
info: ModelResolutionInfo
|
||||
available: AvailableModelsInfo
|
||||
@@ -37,7 +41,7 @@ export function buildModelResolutionDetails(options: {
|
||||
agent.effectiveModel,
|
||||
getEffectiveVariant(agent.name, agent.requirement, options.config)
|
||||
)
|
||||
details.push(` ${marker} ${agent.name}: ${display}`)
|
||||
details.push(` ${marker} ${agent.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(agent.capabilityDiagnostics?.resolutionMode)}]`)
|
||||
}
|
||||
details.push("")
|
||||
details.push("Categories:")
|
||||
@@ -47,7 +51,7 @@ export function buildModelResolutionDetails(options: {
|
||||
category.effectiveModel,
|
||||
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
|
||||
)
|
||||
details.push(` ${marker} ${category.name}: ${display}`)
|
||||
details.push(` ${marker} ${category.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(category.capabilityDiagnostics?.resolutionMode)}]`)
|
||||
}
|
||||
details.push("")
|
||||
details.push("● = user override, ○ = provider fallback")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModelCapabilitiesDiagnostics } from "../../../shared/model-capabilities"
|
||||
import type { ModelRequirement } from "../../../shared/model-requirements"
|
||||
|
||||
export interface AgentResolutionInfo {
|
||||
@@ -7,6 +8,7 @@ export interface AgentResolutionInfo {
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
|
||||
}
|
||||
|
||||
export interface CategoryResolutionInfo {
|
||||
@@ -16,6 +18,7 @@ export interface CategoryResolutionInfo {
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
|
||||
}
|
||||
|
||||
export interface ModelResolutionInfo {
|
||||
|
||||
@@ -129,6 +129,19 @@ describe("model-resolution check", () => {
|
||||
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
|
||||
expect(visual!.userVariant).toBe("high")
|
||||
})
|
||||
|
||||
it("attaches snapshot-backed capability diagnostics for built-in models", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({})
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "snapshot-backed",
|
||||
snapshot: { source: "bundled-snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkModelResolution", () => {
|
||||
@@ -162,6 +175,23 @@ describe("model-resolution check", () => {
|
||||
expect(result.details!.some((d) => d.includes("Categories:"))).toBe(true)
|
||||
// Should have legend
|
||||
expect(result.details!.some((d) => d.includes("user override"))).toBe(true)
|
||||
expect(result.details!.some((d) => d.includes("capabilities: snapshot-backed"))).toBe(true)
|
||||
})
|
||||
|
||||
it("collects warnings when configured models rely on compatibility fallback", async () => {
|
||||
const { collectCapabilityResolutionIssues, getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
agents: {
|
||||
oracle: { model: "custom/unknown-llm" },
|
||||
},
|
||||
})
|
||||
|
||||
const issues = collectCapabilityResolutionIssues(info)
|
||||
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0]?.title).toContain("compatibility fallback")
|
||||
expect(issues[0]?.description).toContain("oracle=custom/unknown-llm")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
|
||||
import { getModelCapabilities } from "../../../shared/model-capabilities"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
@@ -7,16 +8,36 @@ import { buildModelResolutionDetails } from "./model-resolution-details"
|
||||
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
|
||||
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
|
||||
|
||||
export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
}))
|
||||
function parseProviderModel(value: string): { providerID: string; modelID: string } | null {
|
||||
const slashIndex = value.indexOf("/")
|
||||
if (slashIndex <= 0 || slashIndex === value.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => ({
|
||||
return {
|
||||
providerID: value.slice(0, slashIndex),
|
||||
modelID: value.slice(slashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function attachCapabilityDiagnostics<T extends AgentResolutionInfo | CategoryResolutionInfo>(entry: T): T {
|
||||
const parsed = parseProviderModel(entry.effectiveModel)
|
||||
if (!parsed) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
capabilityDiagnostics: getModelCapabilities({
|
||||
providerID: parsed.providerID,
|
||||
modelID: parsed.modelID,
|
||||
}).diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) =>
|
||||
attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
@@ -24,6 +45,16 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
})
|
||||
)
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) =>
|
||||
attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
})
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
@@ -31,34 +62,60 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
|
||||
const userOverride = config.agents?.[name]?.model
|
||||
const userVariant = config.agents?.[name]?.variant
|
||||
return {
|
||||
return attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
const userOverride = config.categories?.[name]?.model
|
||||
const userVariant = config.categories?.[name]?.variant
|
||||
return {
|
||||
return attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
export function collectCapabilityResolutionIssues(info: ModelResolutionInfo): DoctorIssue[] {
|
||||
const issues: DoctorIssue[] = []
|
||||
const allEntries = [...info.agents, ...info.categories]
|
||||
const fallbackEntries = allEntries.filter((entry) => {
|
||||
const mode = entry.capabilityDiagnostics?.resolutionMode
|
||||
return mode === "alias-backed" || mode === "heuristic-backed" || mode === "unknown"
|
||||
})
|
||||
|
||||
if (fallbackEntries.length === 0) {
|
||||
return issues
|
||||
}
|
||||
|
||||
const summary = fallbackEntries
|
||||
.map((entry) => `${entry.name}=${entry.effectiveModel} (${entry.capabilityDiagnostics?.resolutionMode ?? "unknown"})`)
|
||||
.join(", ")
|
||||
|
||||
issues.push({
|
||||
title: "Configured models rely on compatibility fallback",
|
||||
description: summary,
|
||||
severity: "warning",
|
||||
affects: fallbackEntries.map((entry) => entry.name),
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export async function checkModels(): Promise<CheckResult> {
|
||||
const config = loadOmoConfig() ?? {}
|
||||
const info = getModelResolutionInfoWithOverrides(config)
|
||||
@@ -75,6 +132,8 @@ export async function checkModels(): Promise<CheckResult> {
|
||||
})
|
||||
}
|
||||
|
||||
issues.push(...collectCapabilityResolutionIssues(info))
|
||||
|
||||
const overrideCount =
|
||||
info.agents.filter((agent) => Boolean(agent.userOverride)).length +
|
||||
info.categories.filter((category) => Boolean(category.userOverride)).length
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { resolveSymlink } from "../../../shared/file-utils"
|
||||
|
||||
const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test"
|
||||
|
||||
@@ -104,6 +105,31 @@ describe("system loaded version", () => {
|
||||
expect(loadedVersion.expectedVersion).toBe("2.3.4")
|
||||
expect(loadedVersion.loadedVersion).toBe("2.3.4")
|
||||
})
|
||||
|
||||
it("resolves symlinked config directories before selecting install path", () => {
|
||||
//#given
|
||||
const realConfigDir = createTemporaryDirectory("omo-real-config-")
|
||||
const symlinkBaseDir = createTemporaryDirectory("omo-symlink-base-")
|
||||
const symlinkConfigDir = join(symlinkBaseDir, "config-link")
|
||||
|
||||
symlinkSync(realConfigDir, symlinkConfigDir, process.platform === "win32" ? "junction" : "dir")
|
||||
process.env.OPENCODE_CONFIG_DIR = symlinkConfigDir
|
||||
|
||||
writeJson(join(realConfigDir, "package.json"), {
|
||||
dependencies: { [PACKAGE_NAME]: "4.5.6" },
|
||||
})
|
||||
writeJson(join(realConfigDir, "node_modules", PACKAGE_NAME, "package.json"), {
|
||||
version: "4.5.6",
|
||||
})
|
||||
|
||||
//#when
|
||||
const loadedVersion = getLoadedPluginVersion()
|
||||
|
||||
//#then
|
||||
expect(loadedVersion.cacheDir).toBe(resolveSymlink(symlinkConfigDir))
|
||||
expect(loadedVersion.expectedVersion).toBe("4.5.6")
|
||||
expect(loadedVersion.loadedVersion).toBe("4.5.6")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSuggestedInstallTag", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { resolveSymlink } from "../../../shared/file-utils"
|
||||
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
|
||||
import { extractChannel } from "../../../hooks/auto-update-checker"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
@@ -36,6 +36,11 @@ function resolveOpenCodeCacheDir(): string {
|
||||
return platformDefault
|
||||
}
|
||||
|
||||
function resolveExistingDir(dirPath: string): string {
|
||||
if (!existsSync(dirPath)) return dirPath
|
||||
return resolveSymlink(dirPath)
|
||||
}
|
||||
|
||||
function readPackageJson(filePath: string): PackageJsonShape | null {
|
||||
if (!existsSync(filePath)) return null
|
||||
|
||||
@@ -55,12 +60,13 @@ function normalizeVersion(value: string | undefined): string | null {
|
||||
|
||||
export function getLoadedPluginVersion(): LoadedVersionInfo {
|
||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
||||
const cacheDir = resolveOpenCodeCacheDir()
|
||||
const configDir = resolveExistingDir(configPaths.configDir)
|
||||
const cacheDir = resolveExistingDir(resolveOpenCodeCacheDir())
|
||||
const candidates = [
|
||||
{
|
||||
cacheDir: configPaths.configDir,
|
||||
cachePackagePath: configPaths.packageJson,
|
||||
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
|
||||
cacheDir: configDir,
|
||||
cachePackagePath: join(configDir, "package.json"),
|
||||
installedPackagePath: join(configDir, "node_modules", PACKAGE_NAME, "package.json"),
|
||||
},
|
||||
{
|
||||
cacheDir,
|
||||
|
||||
@@ -55,7 +55,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
|
||||
if (role === "librarian") {
|
||||
if (avail.opencodeGo) {
|
||||
agents[role] = { model: "opencode-go/minimax-m2.5" }
|
||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.zai) {
|
||||
agents[role] = { model: ZAI_MODEL }
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
} else if (avail.opencodeZen) {
|
||||
agents[role] = { model: "opencode/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeGo) {
|
||||
agents[role] = { model: "opencode-go/minimax-m2.5" }
|
||||
agents[role] = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.copilot) {
|
||||
agents[role] = { model: "github-copilot/gpt-5-mini" }
|
||||
} else {
|
||||
|
||||
@@ -53,8 +53,8 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.5" })
|
||||
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.5" })
|
||||
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.7" })
|
||||
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.7" })
|
||||
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" })
|
||||
})
|
||||
})
|
||||
|
||||
114
src/cli/refresh-model-capabilities.test.ts
Normal file
114
src/cli/refresh-model-capabilities.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
|
||||
describe("refreshModelCapabilities", () => {
|
||||
it("uses config source_url when CLI override is absent", async () => {
|
||||
const loadConfig = mock(() => ({
|
||||
model_capabilities: {
|
||||
source_url: "https://mirror.example/api.json",
|
||||
},
|
||||
}))
|
||||
const refreshCache = mock(async () => ({
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
sourceUrl: "https://mirror.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{ directory: "/repo", json: false },
|
||||
{
|
||||
loadConfig,
|
||||
refreshCache,
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
stderr: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(loadConfig).toHaveBeenCalledWith("/repo", null)
|
||||
expect(refreshCache).toHaveBeenCalledWith({
|
||||
sourceUrl: "https://mirror.example/api.json",
|
||||
})
|
||||
expect(stdout).toContain("Refreshed model capabilities cache (1 models)")
|
||||
})
|
||||
|
||||
it("CLI sourceUrl overrides config and supports json output", async () => {
|
||||
const refreshCache = mock(async () => ({
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{
|
||||
directory: "/repo",
|
||||
json: true,
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
},
|
||||
{
|
||||
loadConfig: () => ({}),
|
||||
refreshCache,
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
stderr: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(refreshCache).toHaveBeenCalledWith({
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
modelCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns exit code 1 when refresh fails", async () => {
|
||||
let stderr = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{ directory: "/repo" },
|
||||
{
|
||||
loadConfig: () => ({}),
|
||||
refreshCache: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
stdout: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr).toContain("Failed to refresh model capabilities cache")
|
||||
})
|
||||
})
|
||||
51
src/cli/refresh-model-capabilities.ts
Normal file
51
src/cli/refresh-model-capabilities.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { loadPluginConfig } from "../plugin-config"
|
||||
import { refreshModelCapabilitiesCache } from "../shared/model-capabilities-cache"
|
||||
|
||||
export type RefreshModelCapabilitiesOptions = {
|
||||
directory?: string
|
||||
json?: boolean
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
type RefreshModelCapabilitiesDeps = {
|
||||
loadConfig?: typeof loadPluginConfig
|
||||
refreshCache?: typeof refreshModelCapabilitiesCache
|
||||
stdout?: Pick<typeof process.stdout, "write">
|
||||
stderr?: Pick<typeof process.stderr, "write">
|
||||
}
|
||||
|
||||
export async function refreshModelCapabilities(
|
||||
options: RefreshModelCapabilitiesOptions,
|
||||
deps: RefreshModelCapabilitiesDeps = {},
|
||||
): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
const loadConfig = deps.loadConfig ?? loadPluginConfig
|
||||
const refreshCache = deps.refreshCache ?? refreshModelCapabilitiesCache
|
||||
const stdout = deps.stdout ?? process.stdout
|
||||
const stderr = deps.stderr ?? process.stderr
|
||||
|
||||
try {
|
||||
const config = loadConfig(directory, null)
|
||||
const sourceUrl = options.sourceUrl ?? config.model_capabilities?.source_url
|
||||
const snapshot = await refreshCache({ sourceUrl })
|
||||
|
||||
const summary = {
|
||||
sourceUrl: snapshot.sourceUrl,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
modelCount: Object.keys(snapshot.models).length,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
|
||||
} else {
|
||||
stdout.write(
|
||||
`Refreshed model capabilities cache (${summary.modelCount} models) from ${summary.sourceUrl}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
stderr.write(`Failed to refresh model capabilities cache: ${String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
AthenaConfig,
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
@@ -19,5 +20,6 @@ export type {
|
||||
SisyphusConfig,
|
||||
SisyphusTasksConfig,
|
||||
RuntimeFallbackConfig,
|
||||
ModelCapabilitiesConfig,
|
||||
FallbackModels,
|
||||
} from "./schema"
|
||||
|
||||
@@ -147,6 +147,37 @@ describe("disabled_mcps schema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema - model_capabilities", () => {
|
||||
test("accepts valid model capabilities config", () => {
|
||||
const input = {
|
||||
model_capabilities: {
|
||||
enabled: true,
|
||||
auto_refresh_on_start: true,
|
||||
refresh_timeout_ms: 5000,
|
||||
source_url: "https://models.dev/api.json",
|
||||
},
|
||||
}
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(input)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.model_capabilities).toEqual(input.model_capabilities)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects invalid model capabilities config", () => {
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse({
|
||||
model_capabilities: {
|
||||
refresh_timeout_ms: -1,
|
||||
source_url: "not-a-url",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AgentOverrideConfigSchema", () => {
|
||||
describe("category field", () => {
|
||||
test("accepts category as optional string", () => {
|
||||
@@ -371,6 +402,26 @@ describe("CategoryConfigSchema", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts reasoningEffort values none and minimal", () => {
|
||||
// given
|
||||
const noneConfig = { reasoningEffort: "none" }
|
||||
const minimalConfig = { reasoningEffort: "minimal" }
|
||||
|
||||
// when
|
||||
const noneResult = CategoryConfigSchema.safeParse(noneConfig)
|
||||
const minimalResult = CategoryConfigSchema.safeParse(minimalConfig)
|
||||
|
||||
// then
|
||||
expect(noneResult.success).toBe(true)
|
||||
expect(minimalResult.success).toBe(true)
|
||||
if (noneResult.success) {
|
||||
expect(noneResult.data.reasoningEffort).toBe("none")
|
||||
}
|
||||
if (minimalResult.success) {
|
||||
expect(minimalResult.data.reasoningEffort).toBe("minimal")
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects non-string variant", () => {
|
||||
// given
|
||||
const config = { model: "openai/gpt-5.4", variant: 123 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./schema/agent-names"
|
||||
export * from "./schema/agent-overrides"
|
||||
export * from "./schema/athena-config"
|
||||
export * from "./schema/babysitting"
|
||||
export * from "./schema/background-task"
|
||||
export * from "./schema/browser-automation"
|
||||
@@ -13,6 +14,7 @@ export * from "./schema/fallback-models"
|
||||
export * from "./schema/git-env-prefix"
|
||||
export * from "./schema/git-master"
|
||||
export * from "./schema/hooks"
|
||||
export * from "./schema/model-capabilities"
|
||||
export * from "./schema/notification"
|
||||
export * from "./schema/oh-my-opencode-config"
|
||||
export * from "./schema/ralph-loop"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
||||
export const BuiltinAgentNameSchema = z.enum([
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"athena",
|
||||
"prometheus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
@@ -12,6 +13,7 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"momus",
|
||||
"atlas",
|
||||
"sisyphus-junior",
|
||||
"council-member",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
@@ -27,6 +29,7 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"plan",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"athena",
|
||||
"sisyphus-junior",
|
||||
"OpenCode-Builder",
|
||||
"prometheus",
|
||||
|
||||
@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
/** Text verbosity level. */
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
/** Provider-specific options. Passed directly to OpenCode SDK. */
|
||||
@@ -62,6 +62,7 @@ export const AgentOverridesSchema = z.object({
|
||||
hephaestus: AgentOverrideConfigSchema.extend({
|
||||
allow_non_gpt_model: z.boolean().optional(),
|
||||
}).optional(),
|
||||
athena: AgentOverrideConfigSchema.optional(),
|
||||
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
|
||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||
prometheus: AgentOverrideConfigSchema.optional(),
|
||||
|
||||
82
src/config/schema/athena-config.test.ts
Normal file
82
src/config/schema/athena-config.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AthenaConfigSchema } from "./athena-config"
|
||||
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
|
||||
|
||||
describe("AthenaConfigSchema", () => {
|
||||
test("accepts athena config with required members", () => {
|
||||
// given
|
||||
const config = {
|
||||
model: "openai/gpt-5.4",
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "Plato", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects athena config when members are missing", () => {
|
||||
// given
|
||||
const config = {
|
||||
model: "openai/gpt-5.4",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects case-insensitive duplicate member names", () => {
|
||||
// given
|
||||
const config = {
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects member model without provider prefix", () => {
|
||||
// given
|
||||
const config = {
|
||||
members: [{ name: "Socrates", model: "gpt-5.4" }],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema athena field", () => {
|
||||
test("accepts athena config at root", () => {
|
||||
// given
|
||||
const config = {
|
||||
athena: {
|
||||
model: "openai/gpt-5.4",
|
||||
members: [{ name: "Socrates", model: "openai/gpt-5.4" }],
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
39
src/config/schema/athena-config.ts
Normal file
39
src/config/schema/athena-config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const PROVIDER_MODEL_PATTERN = /^[^/\s]+\/[^/\s]+$/
|
||||
|
||||
const ProviderModelSchema = z
|
||||
.string()
|
||||
.regex(PROVIDER_MODEL_PATTERN, "Model must use provider/model format")
|
||||
|
||||
const AthenaCouncilMemberSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
model: ProviderModelSchema,
|
||||
})
|
||||
|
||||
export const AthenaConfigSchema = z
|
||||
.object({
|
||||
model: ProviderModelSchema.optional(),
|
||||
members: z.array(AthenaCouncilMemberSchema).min(1),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seen = new Map<string, number>()
|
||||
|
||||
for (const [index, member] of value.members.entries()) {
|
||||
const normalizedName = member.name.trim().toLowerCase()
|
||||
const existingIndex = seen.get(normalizedName)
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["members", index, "name"],
|
||||
message: `Duplicate member name '${member.name}' (case-insensitive). First seen at members[${existingIndex}]`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
seen.set(normalizedName, index)
|
||||
}
|
||||
})
|
||||
|
||||
export type AthenaConfig = z.infer<typeof AthenaConfigSchema>
|
||||
@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
prompt_append: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
|
||||
export const FallbackModelObjectSchema = z.object({
|
||||
model: z.string(),
|
||||
variant: z.string().optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
thinking: z
|
||||
.object({
|
||||
type: z.enum(["enabled", "disabled"]),
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type FallbackModelObject = z.infer<typeof FallbackModelObjectSchema>
|
||||
|
||||
export const FallbackModelsSchema = z.union([
|
||||
z.string(),
|
||||
z.array(z.union([z.string(), FallbackModelObjectSchema])),
|
||||
])
|
||||
|
||||
export type FallbackModels = z.infer<typeof FallbackModelsSchema>
|
||||
|
||||
@@ -41,6 +41,7 @@ export const HookNameSchema = z.enum([
|
||||
"no-hephaestus-non-gpt",
|
||||
"start-work",
|
||||
"atlas",
|
||||
"agent-switch",
|
||||
"unstable-agent-babysitter",
|
||||
"task-resume-info",
|
||||
"stop-continuation-guard",
|
||||
|
||||
10
src/config/schema/model-capabilities.ts
Normal file
10
src/config/schema/model-capabilities.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ModelCapabilitiesConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
auto_refresh_on_start: z.boolean().optional(),
|
||||
refresh_timeout_ms: z.number().int().positive().optional(),
|
||||
source_url: z.string().url().optional(),
|
||||
})
|
||||
|
||||
export type ModelCapabilitiesConfig = z.infer<typeof ModelCapabilitiesConfigSchema>
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
||||
import { AnyMcpNameSchema } from "../../mcp/types"
|
||||
import { BuiltinSkillNameSchema } from "./agent-names"
|
||||
import { AgentOverridesSchema } from "./agent-overrides"
|
||||
import { AthenaConfigSchema } from "./athena-config"
|
||||
import { BabysittingConfigSchema } from "./babysitting"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
import { BrowserAutomationConfigSchema } from "./browser-automation"
|
||||
@@ -13,6 +14,7 @@ import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { OpenClawConfigSchema } from "./openclaw"
|
||||
import { ModelCapabilitiesConfigSchema } from "./model-capabilities"
|
||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||
import { SkillsConfigSchema } from "./skills"
|
||||
@@ -40,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
|
||||
model_fallback: z.boolean().optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
athena: AthenaConfigSchema.optional(),
|
||||
categories: CategoriesConfigSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
|
||||
@@ -56,6 +59,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
notification: NotificationConfigSchema.optional(),
|
||||
model_capabilities: ModelCapabilitiesConfigSchema.optional(),
|
||||
openclaw: OpenClawConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
log: mock(() => {}),
|
||||
@@ -82,6 +82,10 @@ function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
|
||||
}
|
||||
|
||||
describe("tryFallbackRetry", () => {
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
;(shouldRetryError as any).mockImplementation(() => true)
|
||||
;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0])
|
||||
@@ -274,8 +278,8 @@ describe("tryFallbackRetry", () => {
|
||||
|
||||
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
||||
test("keeps fallback entry and selects connected preferred provider", () => {
|
||||
;(readProviderModelsCache as any).mockReturnValue({ connected: ["provider-a"] })
|
||||
;(selectFallbackProvider as any).mockImplementation(
|
||||
;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] })
|
||||
;(selectFallbackProvider as any).mockImplementationOnce(
|
||||
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import { tmpdir } from "node:os"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
@@ -1636,6 +1637,9 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearSessionPromptParams("session-1")
|
||||
clearSessionPromptParams("session-advanced")
|
||||
clearSessionPromptParams("session-2")
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
@@ -1671,6 +1675,60 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
expect(promptCalls[0].body.agent).toBe("explore")
|
||||
})
|
||||
|
||||
test("should preserve promoted per-model settings when resuming a task", async () => {
|
||||
// given - task resumed after fallback promotion
|
||||
const taskWithAdvancedModel: BackgroundTask = {
|
||||
id: "task-with-advanced-model",
|
||||
sessionID: "session-advanced",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task with advanced model settings",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
variant: "minimal",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.25,
|
||||
top_p: 0.55,
|
||||
maxTokens: 8192,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
getTaskMap(manager).set(taskWithAdvancedModel.id, taskWithAdvancedModel)
|
||||
|
||||
// when
|
||||
await manager.resume({
|
||||
sessionId: "session-advanced",
|
||||
prompt: "continue the work",
|
||||
parentSessionID: "parent-session-2",
|
||||
parentMessageID: "msg-2",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
})
|
||||
expect(promptCalls[0].body.variant).toBe("minimal")
|
||||
expect(promptCalls[0].body.options).toBeUndefined()
|
||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||
temperature: 0.25,
|
||||
topP: 0.55,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should NOT pass model when task has no model (backward compatibility)", async () => {
|
||||
// given - task without model (default behavior)
|
||||
const taskWithoutModel: BackgroundTask = {
|
||||
@@ -2426,6 +2484,133 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(abortCalls).toEqual([createdSessionID])
|
||||
expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
|
||||
})
|
||||
|
||||
test("should release descendant quota when task completes", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-complete"
|
||||
internalTask.rootSessionID = "session-root"
|
||||
|
||||
// Complete via internal method (session.status events go through the poller, not handleEvent)
|
||||
await tryCompleteTaskForTest(manager, internalTask)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when running task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-cancel"
|
||||
|
||||
await manager.cancelTask(task.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when task errors", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-error"
|
||||
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: { sessionID: internalTask.sessionID, info: { id: internalTask.sessionID } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should not double-decrement quota when pending task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 2 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task1 = await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
await manager.cancelTask(task1.id)
|
||||
await manager.cancelTask(task2.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pending task can be cancelled", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
@@ -504,14 +505,20 @@ export class BackgroundManager {
|
||||
})
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
||||
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
if (input.model) {
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
}
|
||||
|
||||
promptWithModelSuggestionRetry(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -543,6 +550,9 @@ export class BackgroundManager {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.rootSessionID) {
|
||||
this.unregisterRootDescendant(existingTask.rootSessionID)
|
||||
}
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
@@ -782,13 +792,19 @@ export class BackgroundManager {
|
||||
})
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// Include model if task has one (preserved from original launch with category config)
|
||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
||||
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
|
||||
const resumeModel = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
? {
|
||||
providerID: existingTask.model.providerID,
|
||||
modelID: existingTask.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
if (existingTask.model) {
|
||||
applySessionPromptParams(existingTask.sessionID!, existingTask.model)
|
||||
}
|
||||
|
||||
this.client.session.promptAsync({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
@@ -813,6 +829,9 @@ export class BackgroundManager {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.rootSessionID) {
|
||||
this.unregisterRootDescendant(existingTask.rootSessionID)
|
||||
}
|
||||
|
||||
// Release concurrency on error to prevent slot leaks
|
||||
if (existingTask.concurrencyKey) {
|
||||
@@ -1009,6 +1028,9 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = errorMsg
|
||||
task.completedAt = new Date()
|
||||
if (task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
@@ -1341,8 +1363,12 @@ export class BackgroundManager {
|
||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||
}
|
||||
|
||||
const wasRunning = task.status === "running"
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
if (wasRunning && task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
if (reason) {
|
||||
task.error = reason
|
||||
}
|
||||
@@ -1463,6 +1489,10 @@ export class BackgroundManager {
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
|
||||
removeTaskToastTracking(task.id)
|
||||
|
||||
// Release concurrency BEFORE any async operations to prevent slot leaks
|
||||
@@ -1701,6 +1731,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.status = "error"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
if (!wasPending && task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -1,68 +1,96 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
import { describe, test, expect, mock, afterEach } from "bun:test"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import {
|
||||
clearSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
} from "../../shared/session-prompt-params-state"
|
||||
|
||||
describe("background-agent spawner.startTask", () => {
|
||||
test("applies explicit child session permission rules when creating child session", async () => {
|
||||
describe("background-agent spawner fallback model promotion", () => {
|
||||
afterEach(() => {
|
||||
clearSessionPromptParams("session-123")
|
||||
})
|
||||
|
||||
test("passes promoted fallback model settings through supported prompt channels", async () => {
|
||||
//#given
|
||||
const createCalls: any[] = []
|
||||
const parentPermission = [
|
||||
{ permission: "question", action: "allow" as const, pattern: "*" },
|
||||
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
|
||||
]
|
||||
|
||||
let promptArgs: any
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
|
||||
create: async (args?: any) => {
|
||||
createCalls.push(args)
|
||||
return { data: { id: "ses_child" } }
|
||||
},
|
||||
promptAsync: async () => ({}),
|
||||
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
|
||||
create: mock(async () => ({ data: { id: "session-123" } })),
|
||||
promptAsync: mock(async (input: any) => {
|
||||
promptArgs = input
|
||||
return { data: {} }
|
||||
}),
|
||||
},
|
||||
}
|
||||
} as any
|
||||
|
||||
const task = createTask({
|
||||
const concurrencyManager = {
|
||||
release: mock(() => {}),
|
||||
} as any
|
||||
|
||||
const onTaskError = mock(() => {})
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "bg_test123",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "explore",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
})
|
||||
|
||||
const item = {
|
||||
task,
|
||||
input: {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
sessionPermission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
prompt: "Do the thing",
|
||||
agent: "oracle",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "message-1",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.4,
|
||||
top_p: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
client,
|
||||
directory: "/fallback",
|
||||
concurrencyManager: { release: () => {} },
|
||||
tmuxEnabled: false,
|
||||
onTaskError: () => {},
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do the thing",
|
||||
agent: "oracle",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "message-1",
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
//#when
|
||||
await startTask(item as any, ctx as any)
|
||||
await startTask(
|
||||
{ task, input },
|
||||
{
|
||||
client,
|
||||
directory: "/tmp/test",
|
||||
concurrencyManager,
|
||||
tmuxEnabled: false,
|
||||
onTaskError,
|
||||
},
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body?.permission).toEqual([
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
expect(promptArgs.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
expect(promptArgs.body.variant).toBe("low")
|
||||
expect(promptArgs.body.options).toBeUndefined()
|
||||
expect(getSessionPromptParams("session-123")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps agent when explicit model is configured", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
@@ -128,10 +129,15 @@ export async function startTask(
|
||||
})
|
||||
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -213,10 +219,15 @@ export async function resumeTask(
|
||||
})
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
? {
|
||||
providerID: task.model.providerID,
|
||||
modelID: task.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
applySessionPromptParams(task.sessionID, task.model)
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: task.sessionID },
|
||||
body: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
|
||||
|
||||
export type BackgroundTaskStatus =
|
||||
@@ -43,7 +44,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
model?: DelegatedModelConfig
|
||||
/** Fallback chain for runtime retry on model errors */
|
||||
fallbackChain?: FallbackEntry[]
|
||||
/** Number of fallback retry attempts made */
|
||||
@@ -76,7 +77,7 @@ export interface LaunchInput {
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
model?: DelegatedModelConfig
|
||||
/** Fallback chain for runtime retry on model errors */
|
||||
fallbackChain?: FallbackEntry[]
|
||||
isUnstableAgent?: boolean
|
||||
|
||||
101
src/features/claude-code-command-loader/loader.test.ts
Normal file
101
src/features/claude-code-command-loader/loader.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`)
|
||||
|
||||
function writeCommand(directory: string, name: string, description: string): void {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, `${name}.md`),
|
||||
`---\ndescription: ${description}\n---\nRun ${name}.\n`,
|
||||
)
|
||||
}
|
||||
|
||||
describe("claude-code command loader", () => {
|
||||
let originalOpencodeConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOpencodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||
}
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given a parent .opencode/commands directory #when loadOpencodeProjectCommands is called from child directory #then it loads the ancestor command", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "desktop")
|
||||
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(childDir)
|
||||
|
||||
// then
|
||||
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
|
||||
})
|
||||
|
||||
it("#given a .opencode/command directory #when loadOpencodeProjectCommands is called #then it loads the singular alias directory", async () => {
|
||||
// given
|
||||
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
|
||||
})
|
||||
|
||||
it("#given duplicate project command names across ancestors #when loadOpencodeProjectCommands is called #then the nearest directory wins", async () => {
|
||||
// given
|
||||
const projectRoot = join(TEST_DIR, "project")
|
||||
const childDir = join(projectRoot, "apps", "desktop")
|
||||
const ancestorDir = join(TEST_DIR, ".opencode", "commands")
|
||||
const projectDir = join(projectRoot, ".opencode", "commands")
|
||||
writeCommand(ancestorDir, "duplicate", "Ancestor command")
|
||||
writeCommand(projectDir, "duplicate", "Nearest command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(childDir)
|
||||
|
||||
// then
|
||||
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
|
||||
})
|
||||
|
||||
it("#given a global .opencode/commands directory #when loadOpencodeGlobalCommands is called #then it loads the plural alias directory", async () => {
|
||||
// given
|
||||
const opencodeConfigDir = join(TEST_DIR, "opencode-config")
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeGlobalCommands()
|
||||
|
||||
// then
|
||||
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
|
||||
})
|
||||
|
||||
it("#given duplicate global command names across profile and parent dirs #when loadOpencodeGlobalCommands is called #then the profile dir wins", async () => {
|
||||
// given
|
||||
const opencodeRootDir = join(TEST_DIR, "opencode-root")
|
||||
const profileConfigDir = join(opencodeRootDir, "profiles", "codex")
|
||||
process.env.OPENCODE_CONFIG_DIR = profileConfigDir
|
||||
writeCommand(join(opencodeRootDir, "commands"), "duplicate-global", "Parent global command")
|
||||
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeGlobalCommands()
|
||||
|
||||
// then
|
||||
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,12 @@ import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
|
||||
import {
|
||||
findProjectOpencodeCommandDirs,
|
||||
getClaudeConfigDir,
|
||||
getOpenCodeCommandDirs,
|
||||
getOpenCodeConfigDir,
|
||||
} from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
@@ -99,9 +104,25 @@ $ARGUMENTS
|
||||
return commands
|
||||
}
|
||||
|
||||
function deduplicateLoadedCommandsByName(commands: LoadedCommand[]): LoadedCommand[] {
|
||||
const seen = new Set<string>()
|
||||
const deduplicatedCommands: LoadedCommand[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
if (seen.has(command.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(command.name)
|
||||
deduplicatedCommands.push(command)
|
||||
}
|
||||
|
||||
return deduplicatedCommands
|
||||
}
|
||||
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
for (const cmd of deduplicateLoadedCommandsByName(commands)) {
|
||||
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
|
||||
result[cmd.name] = openCodeCompatible as CommandDefinition
|
||||
}
|
||||
@@ -121,16 +142,21 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const opencodeCommandsDir = join(configDir, "command")
|
||||
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
const opencodeCommandDirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
||||
const allCommands = await Promise.all(
|
||||
opencodeCommandDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode")),
|
||||
)
|
||||
return commandsToRecord(allCommands.flat())
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
|
||||
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd())
|
||||
const allCommands = await Promise.all(
|
||||
opencodeProjectDirs.map((commandsDir) =>
|
||||
loadCommandsFromDir(commandsDir, "opencode-project"),
|
||||
),
|
||||
)
|
||||
return commandsToRecord(allCommands.flat())
|
||||
}
|
||||
|
||||
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
|
||||
104
src/features/claude-code-plugin-loader/discovery.test.ts
Normal file
104
src/features/claude-code-plugin-loader/discovery.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { discoverInstalledPlugins } from "./discovery"
|
||||
|
||||
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
describe("discoverInstalledPlugins", () => {
|
||||
beforeEach(() => {
|
||||
const pluginsHome = createTemporaryDirectory("omo-claude-plugins-")
|
||||
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudePluginsHome === undefined) {
|
||||
delete process.env.CLAUDE_PLUGINS_HOME
|
||||
} else {
|
||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||
}
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves scoped package name from npm plugin keys", () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "@myorg", "my-plugin")
|
||||
mkdirSync(installPath, { recursive: true })
|
||||
|
||||
const databasePath = join(pluginsHome, "installed_plugins.json")
|
||||
writeFileSync(
|
||||
databasePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
"@myorg/my-plugin@1.0.0": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
//#when
|
||||
const discovered = discoverInstalledPlugins()
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("@myorg/my-plugin")
|
||||
})
|
||||
|
||||
it("derives package name from file URL plugin keys", () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "oh-my-opencode")
|
||||
mkdirSync(installPath, { recursive: true })
|
||||
|
||||
const databasePath = join(pluginsHome, "installed_plugins.json")
|
||||
writeFileSync(
|
||||
databasePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
"file:///D:/configs/user-configs/.config/opencode/node_modules/oh-my-opencode@latest": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath,
|
||||
version: "3.10.0",
|
||||
installedAt: "2026-03-20T00:00:00Z",
|
||||
lastUpdated: "2026-03-20T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
//#when
|
||||
const discovered = discoverInstalledPlugins()
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("oh-my-opencode")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { basename, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { log } from "../../shared/logger"
|
||||
import type {
|
||||
InstalledPluginsDatabase,
|
||||
@@ -79,8 +80,34 @@ function loadPluginManifest(installPath: string): PluginManifest | null {
|
||||
}
|
||||
|
||||
function derivePluginNameFromKey(pluginKey: string): string {
|
||||
const atIndex = pluginKey.indexOf("@")
|
||||
return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey
|
||||
const keyWithoutSource = pluginKey.startsWith("npm:") ? pluginKey.slice(4) : pluginKey
|
||||
|
||||
let versionSeparator: number
|
||||
if (keyWithoutSource.startsWith("@")) {
|
||||
const scopeEnd = keyWithoutSource.indexOf("/")
|
||||
versionSeparator = scopeEnd > 0 ? keyWithoutSource.indexOf("@", scopeEnd) : -1
|
||||
} else {
|
||||
versionSeparator = keyWithoutSource.lastIndexOf("@")
|
||||
}
|
||||
const keyWithoutVersion = versionSeparator > 0 ? keyWithoutSource.slice(0, versionSeparator) : keyWithoutSource
|
||||
|
||||
if (keyWithoutVersion.startsWith("file://")) {
|
||||
try {
|
||||
return basename(fileURLToPath(keyWithoutVersion))
|
||||
} catch {
|
||||
return basename(keyWithoutVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (keyWithoutVersion.startsWith("@") && keyWithoutVersion.includes("/")) {
|
||||
return keyWithoutVersion
|
||||
}
|
||||
|
||||
if (keyWithoutVersion.includes("/") || keyWithoutVersion.includes("\\")) {
|
||||
return basename(keyWithoutVersion)
|
||||
}
|
||||
|
||||
return keyWithoutVersion
|
||||
}
|
||||
|
||||
function isPluginEnabled(
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./state"
|
||||
export * from "./switch-agent-state"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resetPendingSessionAgentSwitchesForTesting } from "./switch-agent-state"
|
||||
|
||||
export const subagentSessions = new Set<string>()
|
||||
export const syncSubagentSessions = new Set<string>()
|
||||
|
||||
@@ -17,6 +19,7 @@ export function _resetForTesting(): void {
|
||||
subagentSessions.clear()
|
||||
syncSubagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
resetPendingSessionAgentSwitchesForTesting()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
import {
|
||||
clearPendingSessionAgentSwitch,
|
||||
consumePendingSessionAgentSwitch,
|
||||
getPendingSessionAgentSwitch,
|
||||
resetPendingSessionAgentSwitchesForTesting,
|
||||
setPendingSessionAgentSwitch,
|
||||
} from "./switch-agent-state"
|
||||
|
||||
describe("switch-agent-state", () => {
|
||||
beforeEach(() => {
|
||||
resetPendingSessionAgentSwitchesForTesting()
|
||||
})
|
||||
|
||||
test("#given pending switch #when consuming #then consumes once and clears", () => {
|
||||
// given
|
||||
setPendingSessionAgentSwitch("ses-1", "explore")
|
||||
|
||||
// when
|
||||
const first = consumePendingSessionAgentSwitch("ses-1")
|
||||
const second = consumePendingSessionAgentSwitch("ses-1")
|
||||
|
||||
// then
|
||||
expect(first?.agent).toBe("explore")
|
||||
expect(second).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given pending switch #when clearing #then state is removed", () => {
|
||||
// given
|
||||
setPendingSessionAgentSwitch("ses-1", "librarian")
|
||||
|
||||
// when
|
||||
clearPendingSessionAgentSwitch("ses-1")
|
||||
|
||||
// then
|
||||
expect(getPendingSessionAgentSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
37
src/features/claude-code-session-state/switch-agent-state.ts
Normal file
37
src/features/claude-code-session-state/switch-agent-state.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
type PendingAgentSwitch = {
|
||||
agent: string
|
||||
requestedAt: Date
|
||||
}
|
||||
|
||||
const pendingAgentSwitchBySession = new Map<string, PendingAgentSwitch>()
|
||||
|
||||
export function setPendingSessionAgentSwitch(sessionID: string, agent: string): PendingAgentSwitch {
|
||||
const pendingSwitch: PendingAgentSwitch = {
|
||||
agent,
|
||||
requestedAt: new Date(),
|
||||
}
|
||||
pendingAgentSwitchBySession.set(sessionID, pendingSwitch)
|
||||
return pendingSwitch
|
||||
}
|
||||
|
||||
export function getPendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
|
||||
return pendingAgentSwitchBySession.get(sessionID)
|
||||
}
|
||||
|
||||
export function consumePendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
|
||||
const pendingSwitch = pendingAgentSwitchBySession.get(sessionID)
|
||||
if (!pendingSwitch) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
pendingAgentSwitchBySession.delete(sessionID)
|
||||
return pendingSwitch
|
||||
}
|
||||
|
||||
export function clearPendingSessionAgentSwitch(sessionID: string): void {
|
||||
pendingAgentSwitchBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
export function resetPendingSessionAgentSwitchesForTesting(): void {
|
||||
pendingAgentSwitchBySession.clear()
|
||||
}
|
||||
@@ -90,6 +90,69 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to root well-known URL when resource has a sub-path", () => {
|
||||
// given — resource URL has a /mcp path (e.g. https://mcp.sentry.dev/mcp)
|
||||
const resource = "https://mcp.example.com/mcp"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const pathSuffixedAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server/mcp"
|
||||
const rootAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server"
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === pathSuffixedAsUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === rootAsUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://mcp.example.com/oauth/authorize",
|
||||
token_endpoint: "https://mcp.example.com/oauth/token",
|
||||
registration_endpoint: "https://mcp.example.com/oauth/register",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://mcp.example.com/oauth/authorize",
|
||||
tokenEndpoint: "https://mcp.example.com/oauth/token",
|
||||
registrationEndpoint: "https://mcp.example.com/oauth/register",
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, pathSuffixedAsUrl, rootAsUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when PRM, path-suffixed AS, and root AS all return 404", () => {
|
||||
// given
|
||||
const resource = "https://mcp.example.com/mcp"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl || url.includes(".well-known/oauth-authorization-server")) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// then
|
||||
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
||||
})
|
||||
|
||||
test("throws when both PRM and AS discovery return 404", () => {
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
|
||||
@@ -36,28 +36,16 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<stri
|
||||
return { ok: true, json }
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
function parseMetadataFields(json: Record<string, unknown>, resource: string): OAuthServerMetadata {
|
||||
const authorizationEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "authorization_endpoint"),
|
||||
readStringField(json, "authorization_endpoint"),
|
||||
"authorization_endpoint"
|
||||
).toString()
|
||||
const tokenEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "token_endpoint"),
|
||||
readStringField(json, "token_endpoint"),
|
||||
"token_endpoint"
|
||||
).toString()
|
||||
const registrationEndpointValue = metadata.json.registration_endpoint
|
||||
const registrationEndpointValue = json.registration_endpoint
|
||||
const registrationEndpoint =
|
||||
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
||||
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
|
||||
@@ -71,6 +59,29 @@ async function fetchAuthorizationServerMetadata(issuer: string, resource: string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404 && issuerPath !== "") {
|
||||
const rootMetadataUrl = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString()
|
||||
const rootMetadata = await fetchMetadata(rootMetadataUrl)
|
||||
if (rootMetadata.ok) {
|
||||
return parseMetadataFields(rootMetadata.json, resource)
|
||||
}
|
||||
}
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
return parseMetadataFields(metadata.json, resource)
|
||||
}
|
||||
|
||||
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
|
||||
const servers = metadata.authorization_servers
|
||||
if (!Array.isArray(servers)) return []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { SkillsConfigSchema } from "../../config/schema/skills"
|
||||
import { discoverConfigSourceSkills, normalizePathForGlob } from "./config-source-discovery"
|
||||
|
||||
@@ -69,6 +69,28 @@ describe("config source discovery", () => {
|
||||
expect(names).not.toContain("skip/skipped-skill")
|
||||
})
|
||||
|
||||
it("loads skills from ~/ sources path", async () => {
|
||||
// given
|
||||
const homeSkillsDir = join(homedir(), `.omo-config-source-${Date.now()}`)
|
||||
writeSkill(join(homeSkillsDir, "tilde-skill"), "tilde-skill", "Loaded from tilde path")
|
||||
const config = SkillsConfigSchema.parse({
|
||||
sources: [{ path: `~/${homeSkillsDir.split(homedir())[1]?.replace(/^\//, "")}`, recursive: true }],
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const skills = await discoverConfigSourceSkills({
|
||||
config,
|
||||
configDir: join(TEST_DIR, "config"),
|
||||
})
|
||||
|
||||
// then
|
||||
expect(skills.some((skill) => skill.name === "tilde-skill")).toBe(true)
|
||||
} finally {
|
||||
rmSync(homeSkillsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("normalizes windows separators before glob matching", () => {
|
||||
// given
|
||||
const windowsPath = "keep\\nested\\SKILL.md"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { dirname, extname, isAbsolute, join, relative } from "path"
|
||||
import picomatch from "picomatch"
|
||||
import type { SkillsConfig } from "../../config/schema"
|
||||
@@ -15,6 +16,14 @@ function isHttpUrl(path: string): boolean {
|
||||
}
|
||||
|
||||
function toAbsolutePath(path: string, configDir: string): string {
|
||||
if (path === "~") {
|
||||
return homedir()
|
||||
}
|
||||
|
||||
if (path.startsWith("~/")) {
|
||||
return join(homedir(), path.slice(2))
|
||||
}
|
||||
|
||||
if (isAbsolute(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -615,5 +615,92 @@ Skill body.
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("project")
|
||||
})
|
||||
|
||||
it("#given a skill in ancestor .agents/skills/ #when discoverProjectAgentsSkills is called from child directory #then it discovers the ancestor skill", async () => {
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: ancestor-agent-skill
|
||||
description: A skill from ancestor .agents/skills directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "worker")
|
||||
const agentsProjectSkillsDir = join(projectDir, ".agents", "skills")
|
||||
const skillDir = join(agentsProjectSkillsDir, "ancestor-agent-skill")
|
||||
mkdirSync(childDir, { recursive: true })
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
|
||||
|
||||
// when
|
||||
const { discoverProjectAgentsSkills } = await import("./loader")
|
||||
const skills = await discoverProjectAgentsSkills(childDir)
|
||||
const skill = skills.find((candidate) => candidate.name === "ancestor-agent-skill")
|
||||
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("project")
|
||||
})
|
||||
})
|
||||
|
||||
describe("opencode project skill discovery", () => {
|
||||
it("#given a skill in ancestor .opencode/skills/ #when discoverOpencodeProjectSkills is called from child directory #then it discovers the ancestor skill", async () => {
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: ancestor-opencode-skill
|
||||
description: A skill from ancestor .opencode/skills directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "packages", "cli")
|
||||
const skillsDir = join(projectDir, ".opencode", "skills", "ancestor-opencode-skill")
|
||||
mkdirSync(childDir, { recursive: true })
|
||||
mkdirSync(skillsDir, { recursive: true })
|
||||
writeFileSync(join(skillsDir, "SKILL.md"), skillContent)
|
||||
|
||||
// when
|
||||
const { discoverOpencodeProjectSkills } = await import("./loader")
|
||||
const skills = await discoverOpencodeProjectSkills(childDir)
|
||||
const skill = skills.find((candidate) => candidate.name === "ancestor-opencode-skill")
|
||||
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("#given a skill in .opencode/skill/ #when discoverOpencodeProjectSkills is called #then it discovers the singular alias directory", async () => {
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: singular-opencode-skill
|
||||
description: A skill from .opencode/skill directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const singularSkillDir = join(
|
||||
TEST_DIR,
|
||||
".opencode",
|
||||
"skill",
|
||||
"singular-opencode-skill",
|
||||
)
|
||||
mkdirSync(singularSkillDir, { recursive: true })
|
||||
writeFileSync(join(singularSkillDir, "SKILL.md"), skillContent)
|
||||
|
||||
// when
|
||||
const { discoverOpencodeProjectSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverOpencodeProjectSkills()
|
||||
const skill = skills.find((candidate) => candidate.name === "singular-opencode-skill")
|
||||
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,11 @@ import { homedir } from "os"
|
||||
import { getClaudeConfigDir } from "../../shared/claude-config-dir"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import { getOpenCodeSkillDirs } from "../../shared/opencode-command-dirs"
|
||||
import {
|
||||
findProjectAgentsSkillDirs,
|
||||
findProjectClaudeSkillDirs,
|
||||
findProjectOpencodeSkillDirs,
|
||||
} from "../../shared/project-discovery-dirs"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { LoadedSkill } from "./types"
|
||||
import { skillsToCommandDefinitionRecord } from "./skill-definition-record"
|
||||
@@ -16,9 +21,11 @@ export async function loadUserSkills(): Promise<Record<string, CommandDefinition
|
||||
}
|
||||
|
||||
export async function loadProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
|
||||
const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
||||
return skillsToCommandDefinitionRecord(skills)
|
||||
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
|
||||
const allSkills = await Promise.all(
|
||||
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||
)
|
||||
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
@@ -30,9 +37,15 @@ export async function loadOpencodeGlobalSkills(): Promise<Record<string, Command
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
|
||||
const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
||||
return skillsToCommandDefinitionRecord(skills)
|
||||
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
|
||||
directory ?? process.cwd(),
|
||||
)
|
||||
const allSkills = await Promise.all(
|
||||
opencodeProjectSkillDirs.map((skillsDir) =>
|
||||
loadSkillsFromDir({ skillsDir, scope: "opencode-project" }),
|
||||
),
|
||||
)
|
||||
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||
}
|
||||
|
||||
export interface DiscoverSkillsOptions {
|
||||
@@ -104,8 +117,11 @@ export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
||||
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
|
||||
const allSkills = await Promise.all(
|
||||
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||
)
|
||||
return deduplicateSkillsByName(allSkills.flat())
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||
@@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
||||
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
|
||||
directory ?? process.cwd(),
|
||||
)
|
||||
const allSkills = await Promise.all(
|
||||
opencodeProjectSkillDirs.map((skillsDir) =>
|
||||
loadSkillsFromDir({ skillsDir, scope: "opencode-project" }),
|
||||
),
|
||||
)
|
||||
return deduplicateSkillsByName(allSkills.flat())
|
||||
}
|
||||
|
||||
export async function discoverProjectAgentsSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
|
||||
const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd())
|
||||
const allSkills = await Promise.all(
|
||||
agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||
)
|
||||
return deduplicateSkillsByName(allSkills.flat())
|
||||
}
|
||||
|
||||
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
|
||||
|
||||
40690
src/generated/model-capabilities.generated.json
Normal file
40690
src/generated/model-capabilities.generated.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { log, normalizeModelID } from "../../shared"
|
||||
|
||||
const OPUS_PATTERN = /claude-opus/i
|
||||
const OPUS_PATTERN = /claude-.*opus/i
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||
|
||||
@@ -45,75 +45,31 @@ function createMockParams(overrides: {
|
||||
}
|
||||
|
||||
describe("createAnthropicEffortHook", () => {
|
||||
describe("opus 4-6 with variant max", () => {
|
||||
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
|
||||
//#given anthropic opus-4-6 model with variant max
|
||||
describe("opus family with variant max", () => {
|
||||
it("injects effort max for anthropic opus-4-6", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected into options
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
|
||||
//#given github-copilot provider with claude-opus-4-6
|
||||
it("injects effort max for another opus family model such as opus-4-5", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "github-copilot",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
const { input, output } = createMockParams({ modelID: "claude-opus-4-5" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected (github-copilot resolves to anthropic)
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
|
||||
//#given opencode provider with claude-opus-4-6
|
||||
it("injects effort max for dotted opus ids", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "opencode",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for google-vertex-anthropic provider", async () => {
|
||||
//#given google-vertex-anthropic provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "google-vertex-anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should handle normalized model ID with dots (opus-4.6)", async () => {
|
||||
//#given model ID with dots instead of hyphens
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-opus-4.6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should normalize and inject effort
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
@@ -133,39 +89,30 @@ describe("createAnthropicEffortHook", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("conditions NOT met - should skip", () => {
|
||||
it("should NOT inject effort when variant is not max", async () => {
|
||||
//#given opus-4-6 with variant high (not max)
|
||||
describe("skip conditions", () => {
|
||||
it("does nothing when variant is not max", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: "high" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort when variant is undefined", async () => {
|
||||
//#given opus-4-6 with no variant
|
||||
it("does nothing when variant is undefined", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: undefined })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should clamp effort to high for non-opus claude model with variant max", async () => {
|
||||
//#given claude-sonnet-4-6 (not opus) with variant max
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-sonnet-4-6",
|
||||
})
|
||||
const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be clamped to high (not max)
|
||||
@@ -173,74 +120,24 @@ describe("createAnthropicEffortHook", () => {
|
||||
expect(input.message.variant).toBe("high")
|
||||
})
|
||||
|
||||
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
|
||||
//#given openai provider with gpt model
|
||||
it("does nothing for non-claude providers/models", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
const { input, output } = createMockParams({ providerID: "openai", modelID: "gpt-5.4" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT throw when model.modelID is undefined", async () => {
|
||||
//#given model with undefined modelID (runtime edge case)
|
||||
const hook = createAnthropicEffortHook()
|
||||
const input = {
|
||||
sessionID: "test-session",
|
||||
agent: { name: "sisyphus" },
|
||||
model: { providerID: "anthropic", modelID: undefined as unknown as string },
|
||||
provider: { id: "anthropic" },
|
||||
message: { variant: "max" as const },
|
||||
}
|
||||
const output = { temperature: 0.1, options: {} }
|
||||
|
||||
//#when chat.params hook is called with undefined modelID
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should gracefully skip without throwing
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("preserves existing options", () => {
|
||||
it("should NOT overwrite existing effort if already set", async () => {
|
||||
//#given options already have effort set
|
||||
describe("existing options", () => {
|
||||
it("does not overwrite existing effort", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: { effort: "high" },
|
||||
})
|
||||
const { input, output } = createMockParams({ existingOptions: { effort: "high" } })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then existing effort should be preserved
|
||||
expect(output.options.effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should preserve other existing options when injecting effort", async () => {
|
||||
//#given options with existing thinking config
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: {
|
||||
thinking: { type: "enabled", budgetTokens: 31999 },
|
||||
},
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be added without affecting thinking
|
||||
expect(output.options.effort).toBe("max")
|
||||
expect(output.options.thinking).toEqual({
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
let testDirectory = ""
|
||||
let promptCalls: Array<unknown> = []
|
||||
|
||||
function writeIncompleteBoulder(): void {
|
||||
function writeIncompleteBoulder(overrides: Partial<BoulderState> = {}): void {
|
||||
const planPath = join(testDirectory, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
...overrides,
|
||||
}
|
||||
|
||||
writeBoulderState(testDirectory, state)
|
||||
@@ -103,6 +104,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: intermediateParentSessionID,
|
||||
@@ -119,4 +121,63 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
|
||||
it("does not inject continuation for boulder-lineage subagent with non-matching agent", async () => {
|
||||
const subagentSessionID = "subagent-session-agent-mismatch"
|
||||
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "sisyphus-junior")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 0)
|
||||
})
|
||||
|
||||
it("injects continuation for boulder-lineage subagent with matching agent", async () => {
|
||||
const subagentSessionID = "subagent-session-agent-match"
|
||||
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
|
||||
it("injects continuation for explicitly tracked boulder session regardless of agent", async () => {
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
setSessionAgent(MAIN_SESSION_ID, "hephaestus")
|
||||
|
||||
const hook = createHook()
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
@@ -136,6 +138,23 @@ export async function handleAtlasSessionIdle(input: {
|
||||
})
|
||||
}
|
||||
|
||||
if (subagentSessions.has(sessionID)) {
|
||||
const sessionAgent = getSessionAgent(sessionID)
|
||||
const agentKey = getAgentConfigKey(sessionAgent ?? "")
|
||||
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas")
|
||||
const agentMatches =
|
||||
agentKey === requiredAgentKey ||
|
||||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
|
||||
if (!agentMatches) {
|
||||
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
agent: sessionAgent ?? "unknown",
|
||||
requiredAgent: boulderState.agent ?? "atlas",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
|
||||
@@ -1282,6 +1282,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
subagentSessions.add(subagentSessionID)
|
||||
updateSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const { describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
mock.module("../../shared/opencode-message-dir", () => ({
|
||||
getMessageDir: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => true,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/normalize-sdk-response", () => ({
|
||||
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
const mockShowConfigErrorsIfAny = mock(async () => {})
|
||||
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
||||
const mockUpdateAndShowConnectedProvidersCacheStatus = mock(async () => {})
|
||||
const mockRefreshModelCapabilitiesOnStartup = mock(async () => {})
|
||||
const mockShowLocalDevToast = mock(async () => {})
|
||||
const mockShowVersionToast = mock(async () => {})
|
||||
const mockRunBackgroundUpdateCheck = mock(async () => {})
|
||||
@@ -22,6 +23,10 @@ mock.module("./hook/connected-providers-status", () => ({
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus,
|
||||
}))
|
||||
|
||||
mock.module("./hook/model-capabilities-status", () => ({
|
||||
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
|
||||
}))
|
||||
|
||||
mock.module("./hook/startup-toasts", () => ({
|
||||
showLocalDevToast: mockShowLocalDevToast,
|
||||
showVersionToast: mockShowVersionToast,
|
||||
@@ -78,6 +83,7 @@ beforeEach(() => {
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
||||
mockRefreshModelCapabilitiesOnStartup.mockClear()
|
||||
mockShowLocalDevToast.mockClear()
|
||||
mockShowVersionToast.mockClear()
|
||||
mockRunBackgroundUpdateCheck.mockClear()
|
||||
@@ -112,6 +118,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
@@ -129,6 +136,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - startup checks, toast, and background check run
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
@@ -146,6 +154,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
@@ -165,6 +174,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - side effects execute only once
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
@@ -183,6 +193,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - local dev toast is shown and background check is skipped
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
@@ -205,6 +216,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
|
||||
@@ -5,11 +5,17 @@ import type { AutoUpdateCheckerOptions } from "./types"
|
||||
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
|
||||
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
|
||||
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
|
||||
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
|
||||
import { showModelCacheWarningIfNeeded } from "./hook/model-cache-warning"
|
||||
import { showLocalDevToast, showVersionToast } from "./hook/startup-toasts"
|
||||
|
||||
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
|
||||
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
|
||||
const {
|
||||
showStartupToast = true,
|
||||
isSisyphusEnabled = false,
|
||||
autoUpdate = true,
|
||||
modelCapabilities,
|
||||
} = options
|
||||
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"
|
||||
|
||||
const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => {
|
||||
@@ -43,6 +49,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
|
||||
|
||||
await showConfigErrorsIfAny(ctx)
|
||||
await updateAndShowConnectedProvidersCacheStatus(ctx)
|
||||
await refreshModelCapabilitiesOnStartup(modelCapabilities)
|
||||
await showModelCacheWarningIfNeeded(ctx)
|
||||
|
||||
if (localDevVersion) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ModelCapabilitiesConfig } from "../../../config/schema/model-capabilities"
|
||||
import { refreshModelCapabilitiesCache } from "../../../shared/model-capabilities-cache"
|
||||
import { log } from "../../../shared/logger"
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 5000
|
||||
|
||||
export async function refreshModelCapabilitiesOnStartup(
|
||||
config: ModelCapabilitiesConfig | undefined,
|
||||
): Promise<void> {
|
||||
if (config?.enabled === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config?.auto_refresh_on_start === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutMs = config?.refresh_timeout_ms ?? DEFAULT_REFRESH_TIMEOUT_MS
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
refreshModelCapabilitiesCache({
|
||||
sourceUrl: config?.source_url,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error("Model capabilities refresh timed out")), timeoutMs)
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
log("[auto-update-checker] Model capabilities refresh failed", { error: String(error) })
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ModelCapabilitiesConfig } from "../../config/schema/model-capabilities"
|
||||
|
||||
export interface NpmDistTags {
|
||||
latest: string
|
||||
[key: string]: string
|
||||
@@ -26,4 +28,5 @@ export interface AutoUpdateCheckerOptions {
|
||||
showStartupToast?: boolean
|
||||
isSisyphusEnabled?: boolean
|
||||
autoUpdate?: boolean
|
||||
modelCapabilities?: ModelCapabilitiesConfig
|
||||
}
|
||||
|
||||
@@ -135,9 +135,96 @@ describe("context-window-monitor modelContextLimitsCache", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Anthropic provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage exceeds the Anthropic default limit", () => {
|
||||
it("#then should ignore the cached limit and append the reminder from the default Anthropic limit", async () => {
|
||||
describe("#given Anthropic 4.6 provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage is below threshold of cached limit", () => {
|
||||
it("#then should respect the cached limit and skip the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_cached_limit_respected"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then — 160K/500K = 32%, well below 70% threshold
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when cached usage exceeds threshold of cached limit", () => {
|
||||
it("#then should use the cached limit for the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_cached_limit_exceeded"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 350000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then — 360K/500K = 72%, above 70% threshold, uses cached 500K limit
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("500,000-token context window")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given older Anthropic provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage would only exceed the incorrect cached limit", () => {
|
||||
it("#then should ignore the cached limit and use the 200K default", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
|
||||
@@ -146,7 +233,7 @@ describe("context-window-monitor modelContextLimitsCache", () => {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_default_overrides_cached_limit"
|
||||
const sessionID = "ses_anthropic_older_model_ignores_cached_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
@@ -176,8 +263,6 @@ describe("context-window-monitor modelContextLimitsCache", () => {
|
||||
// then
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("200,000-token context window")
|
||||
expect(output.output).not.toContain("500,000-token context window")
|
||||
expect(output.output).not.toContain("1,000,000-token context window")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,3 +53,4 @@ export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_P
|
||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||
export { createSwitchAgentHook } from "./switch-agent"
|
||||
|
||||
@@ -293,8 +293,6 @@ NOW.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
export function getDefaultUltraworkMessage(): string {
|
||||
|
||||
@@ -283,8 +283,6 @@ NOW.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
export function getGeminiUltraworkMessage(): string {
|
||||
|
||||
@@ -166,8 +166,6 @@ A task is complete when:
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
export function getGptUltraworkMessage(): string {
|
||||
|
||||
@@ -136,7 +136,5 @@ ${ULTRAWORK_PLANNER_SECTION}
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user