Compare commits
102 Commits
v3.13.0
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
829c58ccb0 | ||
|
|
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 | ||
|
|
77424f86c8 | ||
|
|
919f7e4092 | ||
|
|
78a3e985be | ||
|
|
42fb2548d6 | ||
|
|
bff74f4237 | ||
|
|
038b8a79ec | ||
|
|
0aa8bfe839 | ||
|
|
422eaa9ae0 | ||
|
|
63ebedc9a2 | ||
|
|
f16d55ad95 | ||
|
|
3c49bf3a8c | ||
|
|
29a7bc2d31 | ||
|
|
11f1d71c93 | ||
|
|
62d2704009 | ||
|
|
db32bad004 | ||
|
|
5777bf9894 | ||
|
|
07ea8debdc | ||
|
|
0d52519293 | ||
|
|
031503bb8c | ||
|
|
5986583641 | ||
|
|
3773e370ec | ||
|
|
23a30e86f2 | ||
|
|
0e610a72bc | ||
|
|
04637ff0f1 | ||
|
|
0d96e0d3bc | ||
|
|
719a58270b | ||
|
|
71b1f7e807 | ||
|
|
6455b851b8 | ||
|
|
9346bc8379 | ||
|
|
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 |
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -123,7 +128,7 @@ 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<
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,61 @@ 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" },
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps provider-prefixed overrides for transport while capability diagnostics use pattern aliases", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
categories: {
|
||||
"visual-engineering": { model: "google/gemini-3.1-pro-high" },
|
||||
},
|
||||
})
|
||||
|
||||
const visual = info.categories.find((category) => category.name === "visual-engineering")
|
||||
expect(visual).toBeDefined()
|
||||
expect(visual!.effectiveModel).toBe("google/gemini-3.1-pro-high")
|
||||
expect(visual!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "alias-backed",
|
||||
canonicalization: {
|
||||
source: "pattern-alias",
|
||||
ruleID: "gemini-3.1-pro-tier-alias",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps provider-prefixed Claude overrides for transport while capability diagnostics canonicalize to bare IDs", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-6-thinking" },
|
||||
},
|
||||
})
|
||||
|
||||
const oracle = info.agents.find((agent) => agent.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-6-thinking")
|
||||
expect(oracle!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "alias-backed",
|
||||
canonicalization: {
|
||||
source: "pattern-alias",
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkModelResolution", () => {
|
||||
@@ -162,6 +217,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
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,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 }
|
||||
|
||||
@@ -13,6 +13,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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -13,6 +13,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"
|
||||
@@ -56,6 +57,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(() => {}),
|
||||
@@ -19,6 +19,8 @@ mock.module("../../shared/provider-model-id-transform", () => ({
|
||||
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||
import { selectFallbackProvider } from "../../shared/model-error-classifier"
|
||||
import { readProviderModelsCache } from "../../shared"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -80,8 +82,14 @@ function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
|
||||
}
|
||||
|
||||
describe("tryFallbackRetry", () => {
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
;(shouldRetryError as any).mockImplementation(() => true)
|
||||
;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0])
|
||||
;(readProviderModelsCache as any).mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe("#given retryable error with fallback chain", () => {
|
||||
@@ -267,4 +275,24 @@ describe("tryFallbackRetry", () => {
|
||||
expect(args.task.attemptCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
||||
test("keeps fallback entry and selects connected preferred provider", () => {
|
||||
;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] })
|
||||
;(selectFallbackProvider as any).mockImplementationOnce(
|
||||
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
||||
)
|
||||
|
||||
const args = createDefaultArgs({
|
||||
fallbackChain: [{ model: "fallback-model-1", providers: ["provider-b"], variant: undefined }],
|
||||
model: { providerID: "provider-a", modelID: "original-model" },
|
||||
})
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(args.task.model?.providerID).toBe("provider-a")
|
||||
expect(args.task.model?.modelID).toBe("fallback-model-1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,10 +35,14 @@ export function tryFallbackRetry(args: {
|
||||
const providerModelsCache = readProviderModelsCache()
|
||||
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders.map(p => p.toLowerCase())) : null
|
||||
const preferredProvider = task.model?.providerID?.toLowerCase()
|
||||
|
||||
const isReachable = (entry: FallbackEntry): boolean => {
|
||||
if (!connectedSet) return true
|
||||
return entry.providers.some((p) => connectedSet.has(p.toLowerCase()))
|
||||
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
|
||||
return true
|
||||
}
|
||||
return preferredProvider ? connectedSet.has(preferredProvider) : false
|
||||
}
|
||||
|
||||
let selectedAttemptCount = attemptCount
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1668,7 +1672,61 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
// then - model should be passed in prompt body
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
|
||||
expect("agent" in promptCalls[0].body).toBe(false)
|
||||
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 () => {
|
||||
@@ -1832,7 +1890,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(task2.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("should omit agent when launch has model and keep agent without model", async () => {
|
||||
test("should keep agent when launch has model and keep agent without model", async () => {
|
||||
// given
|
||||
const promptBodies: Array<Record<string, unknown>> = []
|
||||
let resolveFirstPromptStarted: (() => void) | undefined
|
||||
@@ -1894,7 +1952,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(taskWithoutModel.status).toBe("pending")
|
||||
expect(promptBodies).toHaveLength(2)
|
||||
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
expect("agent" in promptBodies[0]).toBe(false)
|
||||
expect(promptBodies[0].agent).toBe("test-agent")
|
||||
expect(promptBodies[1].agent).toBe("test-agent")
|
||||
expect("model" in promptBodies[1]).toBe(false)
|
||||
})
|
||||
@@ -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", () => {
|
||||
@@ -4752,6 +4937,53 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("startTask keeps agent when explicit model is configured", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
create: async () => ({ data: { id: "session-1" } }),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
}
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> })
|
||||
.startTask({ task, input })
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.agent).toBe("sisyphus-junior")
|
||||
expect(promptCalls[0].body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
||||
expect(promptCalls[0].body.variant).toBe("medium")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("resume respects explore agent restrictions", async () => {
|
||||
//#given
|
||||
let capturedTools: Record<string, unknown> | undefined
|
||||
@@ -4796,4 +5028,48 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("resume keeps agent when explicit model is configured", async () => {
|
||||
//#given
|
||||
let promptCall: { path: { id: string }; body: Record<string, unknown> } | undefined
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCall = args
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model-resume",
|
||||
sessionID: "session-3",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
description: "resume task",
|
||||
prompt: "resume prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-3",
|
||||
prompt: "continue",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptCall).toBeDefined()
|
||||
expect(promptCall?.body.agent).toBe("explore")
|
||||
expect(promptCall?.body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,20 +505,24 @@ 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: {
|
||||
// When a model is explicitly provided, omit the agent name so opencode's
|
||||
// built-in agent fallback chain does not override the user-specified model.
|
||||
...(launchModel ? {} : { agent: input.agent }),
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
@@ -545,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
|
||||
@@ -784,19 +792,23 @@ 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: {
|
||||
// When a model is explicitly provided, omit the agent name so opencode's
|
||||
// built-in agent fallback chain does not override the user-specified model.
|
||||
...(resumeModel ? {} : { agent: existingTask.agent }),
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
@@ -817,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) {
|
||||
@@ -1013,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) {
|
||||
@@ -1345,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
|
||||
}
|
||||
@@ -1467,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
|
||||
@@ -1705,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,33 +1,120 @@
|
||||
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: 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 concurrencyManager = {
|
||||
release: mock(() => {}),
|
||||
} as any
|
||||
|
||||
const onTaskError = mock(() => {})
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "bg_test123",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "Test task",
|
||||
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 input = {
|
||||
description: "Test task",
|
||||
prompt: "Do the thing",
|
||||
agent: "oracle",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "message-1",
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
//#when
|
||||
await startTask(
|
||||
{ task, input },
|
||||
{
|
||||
client,
|
||||
directory: "/tmp/test",
|
||||
concurrencyManager,
|
||||
tmuxEnabled: false,
|
||||
onTaskError,
|
||||
},
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#then
|
||||
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 () => {
|
||||
//#given
|
||||
const promptCalls: any[] = []
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
|
||||
create: async (args?: any) => {
|
||||
createCalls.push(args)
|
||||
return { data: { id: "ses_child" } }
|
||||
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||
create: async () => ({ data: { id: "ses_child" } }),
|
||||
promptAsync: async (args?: any) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
}
|
||||
|
||||
const task = createTask({
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "explore",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
})
|
||||
|
||||
const item = {
|
||||
@@ -41,9 +128,6 @@ describe("background-agent spawner.startTask", () => {
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
sessionPermission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,9 +143,12 @@ describe("background-agent spawner.startTask", () => {
|
||||
await startTask(item as any, ctx as any)
|
||||
|
||||
//#then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body?.permission).toEqual([
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||
expect(promptCalls[0]?.body?.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,16 +129,19 @@ 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: {
|
||||
// When a model is explicitly provided, omit the agent name so opencode's
|
||||
// built-in agent fallback chain does not override the user-specified model.
|
||||
...(launchModel ? {} : { agent: input.agent }),
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
@@ -215,16 +219,19 @@ 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: {
|
||||
// When a model is explicitly provided, omit the agent name so opencode's
|
||||
// built-in agent fallback chain does not override the user-specified model.
|
||||
...(resumeModel ? {} : { agent: task.agent }),
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
|
||||
@@ -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
|
||||
|
||||
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,44 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { startCallbackServer, type CallbackServer } from "./callback-server"
|
||||
|
||||
const HOSTNAME = "127.0.0.1"
|
||||
const nativeFetch = Bun.fetch.bind(Bun)
|
||||
|
||||
function supportsRealSocketBinding(): boolean {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("probe"),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const canBindRealSockets = supportsRealSocketBinding()
|
||||
|
||||
type MockServerState = {
|
||||
port: number
|
||||
stopped: boolean
|
||||
fetch: (request: Request) => Response | Promise<Response>
|
||||
}
|
||||
|
||||
describe("startCallbackServer", () => {
|
||||
let server: CallbackServer | null = null
|
||||
let serveSpy: ReturnType<typeof spyOn> | null = null
|
||||
let activeServer: MockServerState | null = null
|
||||
|
||||
async function request(url: string): Promise<Response> {
|
||||
if (canBindRealSockets) {
|
||||
return nativeFetch(url)
|
||||
}
|
||||
|
||||
if (!activeServer || activeServer.stopped) {
|
||||
throw new Error("Connection refused")
|
||||
}
|
||||
|
||||
return await activeServer.fetch(new Request(url))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (canBindRealSockets) {
|
||||
return
|
||||
}
|
||||
|
||||
activeServer = null
|
||||
serveSpy = spyOn(Bun, "serve").mockImplementation((options: {
|
||||
port: number
|
||||
hostname?: string
|
||||
fetch: (request: Request) => Response | Promise<Response>
|
||||
}) => {
|
||||
const state: MockServerState = {
|
||||
port: options.port === 0 ? 19877 : options.port,
|
||||
stopped: false,
|
||||
fetch: options.fetch,
|
||||
}
|
||||
|
||||
const handle = {
|
||||
port: state.port,
|
||||
stop: (_force?: boolean) => {
|
||||
state.stopped = true
|
||||
if (activeServer === state) {
|
||||
activeServer = null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
activeServer = state
|
||||
return handle as ReturnType<typeof Bun.serve>
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
server?.close()
|
||||
server = null
|
||||
// Allow time for port to be released before next test
|
||||
await Bun.sleep(10)
|
||||
|
||||
if (serveSpy) {
|
||||
serveSpy.mockRestore()
|
||||
serveSpy = null
|
||||
}
|
||||
activeServer = null
|
||||
|
||||
if (canBindRealSockets) {
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
})
|
||||
|
||||
it("starts server and returns port", async () => {
|
||||
// given - no preconditions
|
||||
|
||||
// when
|
||||
server = await startCallbackServer()
|
||||
|
||||
// then
|
||||
expect(server.port).toBeGreaterThanOrEqual(19877)
|
||||
expect(typeof server.waitForCallback).toBe("function")
|
||||
expect(typeof server.close).toBe("function")
|
||||
})
|
||||
|
||||
it("resolves callback with code and state from query params", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
const callbackUrl = `http://${HOSTNAME}:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
|
||||
// when
|
||||
// Use Promise.all to ensure fetch and waitForCallback run concurrently
|
||||
// This prevents race condition where waitForCallback blocks before fetch starts
|
||||
const [result, response] = await Promise.all([
|
||||
server.waitForCallback(),
|
||||
nativeFetch(callbackUrl)
|
||||
request(callbackUrl),
|
||||
])
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
@@ -46,25 +114,19 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 404 for non-callback routes", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/other`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when code is missing", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
const callbackRejection = server.waitForCallback().catch((error: Error) => error)
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?state=s`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -72,14 +134,11 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when state is missing", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
const callbackRejection = server.waitForCallback().catch((error: Error) => error)
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?code=c`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -87,18 +146,15 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("close stops the server immediately", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const port = server.port
|
||||
|
||||
// when
|
||||
server.close()
|
||||
server = null
|
||||
|
||||
// then
|
||||
try {
|
||||
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
|
||||
expect(true).toBe(false)
|
||||
await request(`http://${HOSTNAME}:${port}/oauth/callback?code=c&state=s`)
|
||||
expect.unreachable("request should fail after close")
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promi
|
||||
}
|
||||
|
||||
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
|
||||
const port = await findAvailablePort(startPort)
|
||||
const requestedPort = await findAvailablePort(startPort).catch(() => 0)
|
||||
|
||||
let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
@@ -55,7 +55,7 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
port: requestedPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
@@ -93,9 +93,10 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
||||
})
|
||||
},
|
||||
})
|
||||
const activePort = server.port ?? requestedPort
|
||||
|
||||
return {
|
||||
port,
|
||||
port: activePort,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
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_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||
const OPUS_PATTERN = /claude-.*opus/i
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||
@@ -8,9 +8,9 @@ function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isOpus46(modelID: string): boolean {
|
||||
function isOpusModel(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return OPUS_4_6_PATTERN.test(normalized)
|
||||
return OPUS_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
interface ChatParamsInput {
|
||||
@@ -28,6 +28,20 @@ interface ChatParamsOutput {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid thinking budget levels per model tier.
|
||||
* Opus supports "max"; all other Claude models cap at "high".
|
||||
*/
|
||||
const MAX_VARIANT_BY_TIER: Record<string, string> = {
|
||||
opus: "max",
|
||||
default: "high",
|
||||
}
|
||||
|
||||
function clampVariant(variant: string, isOpus: boolean): string {
|
||||
if (variant !== "max") return variant
|
||||
return isOpus ? MAX_VARIANT_BY_TIER.opus : MAX_VARIANT_BY_TIER.default
|
||||
}
|
||||
|
||||
export function createAnthropicEffortHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
@@ -38,15 +52,27 @@ export function createAnthropicEffortHook() {
|
||||
if (!model?.modelID || !model?.providerID) return
|
||||
if (message.variant !== "max") return
|
||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||
if (!isOpus46(model.modelID)) return
|
||||
if (output.options.effort !== undefined) return
|
||||
|
||||
output.options.effort = "max"
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
const opus = isOpusModel(model.modelID)
|
||||
const clamped = clampVariant(message.variant, opus)
|
||||
output.options.effort = clamped
|
||||
|
||||
if (!opus) {
|
||||
// Override the variant so OpenCode doesn't pass "max" to the API
|
||||
;(message as { variant?: string }).variant = clamped
|
||||
log("anthropic-effort: clamped variant max→high for non-Opus model", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
} else {
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,186 +45,99 @@ 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({ modelID: "claude-opus-4-5" })
|
||||
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("injects effort max for dotted opus ids", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
|
||||
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should preserve max for other opus model IDs such as opus-4-5", async () => {
|
||||
//#given another opus model id that is not 4.6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "github-copilot",
|
||||
modelID: "claude-opus-4-6",
|
||||
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
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "opencode",
|
||||
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
|
||||
//#then max should still be treated as valid for opus family
|
||||
expect(output.options.effort).toBe("max")
|
||||
expect(input.message.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
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 NOT inject effort for non-opus model", async () => {
|
||||
//#given claude-sonnet-4-6 (not opus)
|
||||
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 NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
//#then effort should be clamped to high (not max)
|
||||
expect(output.options.effort).toBe("high")
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@ const { beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
const readConnectedProvidersCacheMock = mock(() => null)
|
||||
const readProviderModelsCacheMock = mock(() => null)
|
||||
const selectFallbackProviderMock = mock((providers: string[], preferredProviderID?: string) => {
|
||||
const connectedProviders = readConnectedProvidersCacheMock()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders.map((provider: string) => provider.toLowerCase()))
|
||||
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider.toLowerCase())) {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredProviderID && connectedSet.has(preferredProviderID.toLowerCase())) {
|
||||
return preferredProviderID
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] || preferredProviderID || "opencode"
|
||||
})
|
||||
const transformModelForProviderMock = mock((provider: string, model: string) => {
|
||||
if (provider === "github-copilot") {
|
||||
return model
|
||||
@@ -31,6 +49,10 @@ mock.module("../../shared/provider-model-id-transform", () => ({
|
||||
transformModelForProvider: transformModelForProviderMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/model-error-classifier", () => ({
|
||||
selectFallbackProvider: selectFallbackProviderMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
clearPendingModelFallback,
|
||||
createModelFallbackHook,
|
||||
@@ -44,6 +66,7 @@ describe("model fallback hook", () => {
|
||||
readProviderModelsCacheMock.mockReturnValue(null)
|
||||
readConnectedProvidersCacheMock.mockClear()
|
||||
readProviderModelsCacheMock.mockClear()
|
||||
selectFallbackProviderMock.mockClear()
|
||||
|
||||
clearPendingModelFallback("ses_model_fallback_main")
|
||||
clearPendingModelFallback("ses_model_fallback_ghcp")
|
||||
@@ -255,6 +278,50 @@ describe("model fallback hook", () => {
|
||||
clearPendingModelFallback(sessionID)
|
||||
})
|
||||
|
||||
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_model_fallback_preferred_provider"
|
||||
clearPendingModelFallback(sessionID)
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||
|
||||
const hook = createModelFallbackHook() as unknown as {
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
setSessionFallbackChain(sessionID, [
|
||||
{ providers: ["provider-y"], model: "fallback-model" },
|
||||
])
|
||||
|
||||
expect(
|
||||
setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"provider-x",
|
||||
"current-model",
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
model: { providerID: "provider-x", modelID: "current-model" },
|
||||
},
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["chat.message"]?.({ sessionID }, output)
|
||||
|
||||
//#then
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "provider-x",
|
||||
modelID: "fallback-model",
|
||||
})
|
||||
clearPendingModelFallback(sessionID)
|
||||
})
|
||||
|
||||
test("shows toast when fallback is applied", async () => {
|
||||
//#given
|
||||
const toastCalls: Array<{ title: string; message: string }> = []
|
||||
|
||||
@@ -130,14 +130,21 @@ export function getNextFallback(
|
||||
|
||||
const providerModelsCache = readProviderModelsCache()
|
||||
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
const connectedSet = connectedProviders
|
||||
? new Set(connectedProviders.map((provider) => provider.toLowerCase()))
|
||||
: null
|
||||
|
||||
const isReachable = (entry: FallbackEntry): boolean => {
|
||||
if (!connectedSet) return true
|
||||
|
||||
// Gate only on provider connectivity. Provider model lists can be stale/incomplete,
|
||||
// especially after users manually add models to opencode.json.
|
||||
return entry.providers.some((p) => connectedSet.has(p))
|
||||
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const preferredProvider = state.providerID.toLowerCase()
|
||||
return connectedSet.has(preferredProvider)
|
||||
}
|
||||
|
||||
while (state.attemptCount < fallbackChain.length) {
|
||||
@@ -267,3 +274,13 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all module-global state for testing.
|
||||
* Clears pending fallbacks, toast keys, and session chains.
|
||||
*/
|
||||
export function _resetForTesting(): void {
|
||||
pendingModelFallbacks.clear()
|
||||
lastToastKey.clear()
|
||||
sessionFallbackChains.clear()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
|
||||
*/
|
||||
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
||||
enabled: false,
|
||||
retry_on_errors: [429, 500, 502, 503, 504],
|
||||
retry_on_errors: [402, 429, 500, 502, 503, 504],
|
||||
max_fallback_attempts: 3,
|
||||
cooldown_seconds: 60,
|
||||
timeout_seconds: 30,
|
||||
@@ -37,6 +37,11 @@ export const RETRYABLE_ERROR_PATTERNS = [
|
||||
/try.?again/i,
|
||||
/credit.*balance.*too.*low/i,
|
||||
/insufficient.?(?:credits?|funds?|balance)/i,
|
||||
/subscription.*quota/i,
|
||||
/billing.?(?:hard.?)?limit/i,
|
||||
/payment.?required/i,
|
||||
/out\s+of\s+credits?/i,
|
||||
/(?:^|\s)402(?:\s|$)/,
|
||||
/(?:^|\s)429(?:\s|$)/,
|
||||
/(?:^|\s)503(?:\s|$)/,
|
||||
/(?:^|\s)529(?:\s|$)/,
|
||||
|
||||
@@ -31,6 +31,20 @@ describe("runtime-fallback error classifier", () => {
|
||||
expect(signal).toBeDefined()
|
||||
})
|
||||
|
||||
test("detects too-many-requests auto-retry status signals without countdown text", () => {
|
||||
//#given
|
||||
const info = {
|
||||
status:
|
||||
"Too Many Requests: Sorry, you've exhausted this model's rate limit. Please try a different model.",
|
||||
}
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info)
|
||||
|
||||
//#then
|
||||
expect(signal).toBeDefined()
|
||||
})
|
||||
|
||||
test("treats cooling-down retry messages as retryable", () => {
|
||||
//#given
|
||||
const error = {
|
||||
@@ -166,3 +180,100 @@ describe("extractStatusCode", () => {
|
||||
expect(extractStatusCode(error)).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe("quota error detection (fixes #2747)", () => {
|
||||
test("classifies prettified subscription quota error as quota_exceeded", () => {
|
||||
//#given
|
||||
const error = {
|
||||
name: "AI_APICallError",
|
||||
message: "Subscription quota exceeded. You can continue using free models.",
|
||||
}
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies billing hard limit error as quota_exceeded", () => {
|
||||
//#given
|
||||
const error = { message: "You have reached your billing hard limit." }
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
})
|
||||
|
||||
test("classifies exhausted capacity error as quota_exceeded", () => {
|
||||
//#given
|
||||
const error = { message: "You have exhausted your capacity on this model." }
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
})
|
||||
|
||||
test("classifies out of credits error as quota_exceeded", () => {
|
||||
//#given
|
||||
const error = { message: "Out of credits. Please add more credits to continue." }
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
})
|
||||
|
||||
test("treats HTTP 402 Payment Required as retryable", () => {
|
||||
//#given
|
||||
const error = { statusCode: 402, message: "Payment Required" }
|
||||
|
||||
//#when
|
||||
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
|
||||
|
||||
//#then
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
|
||||
test("matches subscription quota pattern in RETRYABLE_ERROR_PATTERNS", () => {
|
||||
//#given
|
||||
const error = { message: "Subscription quota exceeded. You can continue using free models." }
|
||||
|
||||
//#when
|
||||
const retryable = isRetryableError(error, [429, 503])
|
||||
|
||||
//#then
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies QuotaExceededError by errorName even without quota keywords in message", () => {
|
||||
//#given
|
||||
const error = { name: "QuotaExceededError", message: "Request failed." }
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
})
|
||||
|
||||
test("detects payment required errors as retryable", () => {
|
||||
//#given
|
||||
const error = { message: "Error 402: payment required for this request" }
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
const retryable = isRetryableError(error, [429, 503])
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,13 @@ export function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const errorObj2 = error as Record<string, unknown>
|
||||
const name = errorObj2.name
|
||||
if (typeof name === "string" && name.length > 0) {
|
||||
const nameColonMatch = name.match(/:\s*(.+)/)
|
||||
if (nameColonMatch) return nameColonMatch[1].trim().toLowerCase()
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error).toLowerCase()
|
||||
} catch {
|
||||
@@ -112,6 +119,21 @@ export function classifyErrorType(error: unknown): string | undefined {
|
||||
return "model_not_found"
|
||||
}
|
||||
|
||||
if (
|
||||
errorName?.includes("quotaexceeded") ||
|
||||
errorName?.includes("insufficientquota") ||
|
||||
errorName?.includes("billingerror") ||
|
||||
/quota.?exceeded/i.test(message) ||
|
||||
/subscription.*quota/i.test(message) ||
|
||||
/insufficient.?quota/i.test(message) ||
|
||||
/billing.?(?:hard.?)?limit/i.test(message) ||
|
||||
/exhausted\s+your\s+capacity/i.test(message) ||
|
||||
/out\s+of\s+credits?/i.test(message) ||
|
||||
/payment.?required/i.test(message)
|
||||
) {
|
||||
return "quota_exceeded"
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -145,7 +167,7 @@ export function extractAutoRetrySignal(info: Record<string, unknown> | undefined
|
||||
const combined = candidates.join("\n")
|
||||
if (!combined) return undefined
|
||||
|
||||
const isAutoRetry = AUTO_RETRY_PATTERNS.every((test) => test(combined))
|
||||
const isAutoRetry = AUTO_RETRY_PATTERNS.some((test) => test(combined))
|
||||
if (isAutoRetry) {
|
||||
return { signal: combined }
|
||||
}
|
||||
@@ -181,6 +203,10 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
|
||||
return true
|
||||
}
|
||||
|
||||
if (errorType === "quota_exceeded") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (statusCode && retryOnErrors.includes(statusCode)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import type { FallbackModelObject } from "../../config/schema/fallback-models"
|
||||
import { agentPattern } from "./agent-resolver"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
|
||||
/**
|
||||
* Returns fallback model strings for the runtime-fallback system.
|
||||
* Object entries are flattened to "provider/model(variant)" strings so the
|
||||
* string-based fallback state machine can work with them unchanged.
|
||||
*/
|
||||
export function getFallbackModelsForSession(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
@@ -12,22 +18,45 @@ export function getFallbackModelsForSession(
|
||||
): string[] {
|
||||
if (!pluginConfig) return []
|
||||
|
||||
const raw = getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||
return flattenToFallbackModelStrings(raw) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw fallback model entries (strings and objects) for a session.
|
||||
* Use this when per-model settings (temperature, reasoningEffort, etc.) must be
|
||||
* preserved — e.g. before passing to buildFallbackChainFromModels.
|
||||
*/
|
||||
export function getRawFallbackModels(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
pluginConfig: OhMyOpenCodeConfig | undefined,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
if (!pluginConfig) return undefined
|
||||
return getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||
}
|
||||
|
||||
function getRawFallbackModelsForSession(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
const sessionCategory = SessionCategoryRegistry.get(sessionID)
|
||||
if (sessionCategory && pluginConfig.categories?.[sessionCategory]) {
|
||||
const categoryConfig = pluginConfig.categories[sessionCategory]
|
||||
if (categoryConfig?.fallback_models) {
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models) ?? []
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models)
|
||||
}
|
||||
}
|
||||
|
||||
const tryGetFallbackFromAgent = (agentName: string): string[] | undefined => {
|
||||
const tryGetFallbackFromAgent = (agentName: string): (string | FallbackModelObject)[] | undefined => {
|
||||
const agentConfig = pluginConfig.agents?.[agentName as keyof typeof pluginConfig.agents]
|
||||
if (!agentConfig) return undefined
|
||||
|
||||
|
||||
if (agentConfig?.fallback_models) {
|
||||
return normalizeFallbackModels(agentConfig.fallback_models)
|
||||
}
|
||||
|
||||
|
||||
const agentCategory = agentConfig?.category
|
||||
if (agentCategory && pluginConfig.categories?.[agentCategory]) {
|
||||
const categoryConfig = pluginConfig.categories[agentCategory]
|
||||
@@ -35,7 +64,7 @@ export function getFallbackModelsForSession(
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -53,5 +82,5 @@ export function getFallbackModelsForSession(
|
||||
|
||||
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
|
||||
|
||||
return []
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { RuntimeFallbackPluginInput } from "./types"
|
||||
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||
import { extractAutoRetrySignal } from "./error-classifier"
|
||||
|
||||
function createContext(messagesResponse: unknown): RuntimeFallbackPluginInput {
|
||||
return {
|
||||
@@ -53,4 +54,29 @@ describe("hasVisibleAssistantResponse", () => {
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given a too-many-requests assistant reply #when visibility is checked #then it is treated as an auto-retry signal", async () => {
|
||||
// given
|
||||
const checkVisibleResponse = hasVisibleAssistantResponse(extractAutoRetrySignal)
|
||||
const ctx = createContext({
|
||||
data: [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "latest question" }] },
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Too Many Requests: Sorry, you've exhausted this model's rate limit. Please try a different model.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await checkVisibleResponse(ctx, "session-rate-limit", undefined)
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HookDeps } from "./types"
|
||||
import type { AutoRetryHelpers } from "./auto-retry"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { HOOK_NAME, RETRYABLE_ERROR_PATTERNS } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { extractAutoRetrySignal } from "./error-classifier"
|
||||
import { createFallbackState } from "./fallback-state"
|
||||
@@ -32,7 +32,14 @@ export function createSessionStatusHandler(
|
||||
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
|
||||
if (!retrySignal) return
|
||||
if (!retrySignal) {
|
||||
// Fallback: status.type is already "retry", so check the message against
|
||||
// retryable error patterns directly. This handles providers like Gemini whose
|
||||
// retry status message may not contain "retrying in" text alongside the error.
|
||||
const messageLower = retryMessage.toLowerCase()
|
||||
const matchesRetryablePattern = RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(messageLower))
|
||||
if (!matchesRetryablePattern) return
|
||||
}
|
||||
|
||||
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
|
||||
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
|
||||
|
||||
@@ -404,6 +404,24 @@ describe("start-work hook", () => {
|
||||
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "atlas")
|
||||
updateSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should stamp the outgoing message with Atlas so follow-up events keep the handoff", async () => {
|
||||
// given
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "ses-prometheus-to-atlas" },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Atlas (Plan Executor)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("worktree support", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { detectWorktreePath } from "./worktree-detector"
|
||||
import { parseUserRequest } from "./parse-user-request"
|
||||
@@ -23,6 +24,7 @@ interface StartWorkHookInput {
|
||||
}
|
||||
|
||||
interface StartWorkHookOutput {
|
||||
message?: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
@@ -79,6 +81,9 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
|
||||
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
||||
updateSessionAgent(input.sessionID, "atlas")
|
||||
if (output.message) {
|
||||
output.message["agent"] = getAgentDisplayName("atlas")
|
||||
}
|
||||
|
||||
const existingState = readBoulderState(ctx.directory)
|
||||
const sessionId = input.sessionID
|
||||
|
||||
@@ -1,108 +1,184 @@
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
declare const describe: (name: string, fn: () => void) => void
|
||||
declare const it: (name: string, fn: () => void | Promise<void>) => void
|
||||
declare const expect: <T>(value: T) => {
|
||||
toBe(expected: T): void
|
||||
toEqual(expected: unknown): void
|
||||
toHaveLength(expected: number): void
|
||||
}
|
||||
|
||||
const { createThinkingBlockValidatorHook } = require("./hook")
|
||||
import { createThinkingBlockValidatorHook } from "./hook"
|
||||
|
||||
type TestPart = {
|
||||
type: string
|
||||
id: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
data?: string
|
||||
signature?: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
|
||||
type TestMessage = {
|
||||
info: {
|
||||
role: string
|
||||
id?: string
|
||||
modelID?: string
|
||||
}
|
||||
info: { role: "assistant" | "user" }
|
||||
parts: TestPart[]
|
||||
}
|
||||
|
||||
function createMessage(info: TestMessage["info"], parts: TestPart[]): TestMessage {
|
||||
return { info, parts }
|
||||
}
|
||||
async function runTransform(messages: TestMessage[]): Promise<void> {
|
||||
const hook = createThinkingBlockValidatorHook()
|
||||
const transform = hook["experimental.chat.messages.transform"]
|
||||
|
||||
function createTextPart(id: string, text: string): TestPart {
|
||||
return { type: "text", id, text }
|
||||
}
|
||||
if (!transform) {
|
||||
throw new Error("missing thinking block validator transform")
|
||||
}
|
||||
|
||||
function createSignedThinkingPart(id: string, thinking: string, signature: string): TestPart {
|
||||
return { type: "thinking", id, thinking, signature }
|
||||
}
|
||||
|
||||
function createRedactedThinkingPart(id: string, signature: string): TestPart {
|
||||
return { type: "redacted_thinking", id, data: "encrypted", signature }
|
||||
await transform({}, { messages: messages as never })
|
||||
}
|
||||
|
||||
describe("createThinkingBlockValidatorHook", () => {
|
||||
test("reuses the previous signed thinking part verbatim when assistant content lacks a leading thinking block", async () => {
|
||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
||||
expect(typeof transform).toBe("function")
|
||||
it("injects signed thinking history verbatim", async () => {
|
||||
//#given
|
||||
const signedThinkingPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "plan",
|
||||
signature: "signed-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
|
||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
||||
const messages: TestMessage[] = [
|
||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
||||
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart, createTextPart("prt_prev_text", "done")]),
|
||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
||||
]
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
||||
|
||||
expect(messages[2]?.parts[0]).toBe(previousThinkingPart)
|
||||
expect(messages[2]?.parts).toEqual([previousThinkingPart, targetTextPart])
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(signedThinkingPart)
|
||||
})
|
||||
|
||||
test("skips injection when no signed Anthropic thinking part exists in history", async () => {
|
||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
||||
expect(typeof transform).toBe("function")
|
||||
it("injects signed redacted_thinking history verbatim", async () => {
|
||||
//#given
|
||||
const signedRedactedThinkingPart: TestPart = {
|
||||
type: "redacted_thinking",
|
||||
signature: "signed-redacted-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedRedactedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "tool_use" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
||||
const messages: TestMessage[] = [
|
||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
||||
createMessage({ role: "assistant", id: "msg_prev" }, [{ type: "reasoning", id: "prt_reason", text: "gpt reasoning" }]),
|
||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
||||
]
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
||||
|
||||
expect(messages[2]?.parts).toEqual([targetTextPart])
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart)
|
||||
})
|
||||
|
||||
test("does not inject when the assistant message already starts with redacted thinking", async () => {
|
||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
||||
expect(typeof transform).toBe("function")
|
||||
it("skips hook when history contains reasoning only", async () => {
|
||||
//#given
|
||||
const reasoningPart: TestPart = {
|
||||
type: "reasoning",
|
||||
text: "internal reasoning",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [reasoningPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
const existingThinkingPart = createRedactedThinkingPart("prt_redacted", "sig_redacted")
|
||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
||||
const messages: TestMessage[] = [
|
||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
||||
createMessage({ role: "assistant", id: "msg_target" }, [existingThinkingPart, targetTextPart]),
|
||||
]
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
||||
|
||||
expect(messages[1]?.parts).toEqual([existingThinkingPart, targetTextPart])
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
test("skips processing for models without extended thinking", async () => {
|
||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
||||
expect(typeof transform).toBe("function")
|
||||
it("skips hook when no signed history exists", async () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "thinking", thinking: "draft" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
|
||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
||||
const messages: TestMessage[] = [
|
||||
createMessage({ role: "user", modelID: "gpt-5.4" }, [createTextPart("prt_user_text", "continue")]),
|
||||
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart]),
|
||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
||||
]
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
expect(messages[2]?.parts).toEqual([targetTextPart])
|
||||
it("skips hook when history contains synthetic signed blocks only", async () => {
|
||||
//#given
|
||||
const syntheticSignedPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "synthetic",
|
||||
signature: "synthetic-signature",
|
||||
synthetic: true,
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [syntheticSignedPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
it("does not reinject when the message already starts with redacted_thinking", async () => {
|
||||
//#given
|
||||
const signedThinkingPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "plan",
|
||||
signature: "signed-thinking",
|
||||
}
|
||||
const leadingRedactedThinkingPart: TestPart = {
|
||||
type: "redacted_thinking",
|
||||
signature: "existing-redacted-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [leadingRedactedThinkingPart, { type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(leadingRedactedThinkingPart)
|
||||
expect(messages[1]?.parts).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -21,11 +21,6 @@ interface MessageWithParts {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type SignedThinkingPart = Part & {
|
||||
type: "thinking" | "redacted_thinking"
|
||||
signature: string
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
@@ -33,25 +28,39 @@ type MessagesTransformHook = {
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has extended thinking enabled
|
||||
* Uses patterns from think-mode/switcher.ts for consistency
|
||||
*/
|
||||
function isExtendedThinkingModel(modelID: string): boolean {
|
||||
if (!modelID) return false
|
||||
const lower = modelID.toLowerCase()
|
||||
type SignedThinkingPart = Part & {
|
||||
type: "thinking" | "redacted_thinking"
|
||||
thinking?: string
|
||||
signature: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
|
||||
// Check for explicit thinking/high variants (always enabled)
|
||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||
return true
|
||||
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
||||
const type = part.type as string
|
||||
if (type !== "thinking" && type !== "redacted_thinking") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||
return (
|
||||
lower.includes("claude-sonnet-4") ||
|
||||
lower.includes("claude-opus-4") ||
|
||||
lower.includes("claude-3")
|
||||
const signature = (part as { signature?: unknown }).signature
|
||||
const synthetic = (part as { synthetic?: unknown }).synthetic
|
||||
return typeof signature === "string" && signature.length > 0 && synthetic !== true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any Anthropic-signed thinking blocks in the message history.
|
||||
*
|
||||
* Only returns true for real `type: "thinking"` blocks with a valid `signature`.
|
||||
* GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded — they
|
||||
* have no Anthropic signature and must never be forwarded to the Anthropic API.
|
||||
*
|
||||
* Model-name checks are unreliable (miss GPT+thinking, custom model IDs, etc.)
|
||||
* so we inspect the messages themselves.
|
||||
*/
|
||||
function hasSignedThinkingBlocksInHistory(messages: MessageWithParts[]): boolean {
|
||||
return messages.some(
|
||||
m =>
|
||||
m.info.role === "assistant" &&
|
||||
m.parts?.some((p: Part) => isSignedThinkingPart(p)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,36 +88,42 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
|
||||
}
|
||||
|
||||
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
||||
const type = part.type as string
|
||||
if (type !== "thinking" && type !== "redacted_thinking") {
|
||||
return false
|
||||
}
|
||||
|
||||
const signature = (part as { signature?: unknown }).signature
|
||||
return typeof signature === "string" && signature.length > 0
|
||||
}
|
||||
|
||||
function findPreviousThinkingPart(
|
||||
messages: MessageWithParts[],
|
||||
currentIndex: number
|
||||
): SignedThinkingPart | null {
|
||||
/**
|
||||
* Find the most recent Anthropic-signed thinking part from previous assistant messages.
|
||||
*
|
||||
* Returns the original Part object (including its `signature` field) so it can
|
||||
* be reused verbatim in another message. Only `type: "thinking"` blocks with
|
||||
* both a `signature` and `thinking` field are returned — GPT `type: "reasoning"`
|
||||
* blocks are excluded because they lack an Anthropic signature and would be
|
||||
* rejected by the API with "Invalid `signature` in `thinking` block".
|
||||
* Synthetic parts injected by a previous run of this hook are also skipped.
|
||||
*/
|
||||
function findPreviousThinkingPart(messages: MessageWithParts[], currentIndex: number): SignedThinkingPart | null {
|
||||
// Search backwards from current message
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (isSignedThinkingPart(part)) {
|
||||
return part
|
||||
}
|
||||
// Only Anthropic thinking blocks — type must be "thinking", not "reasoning"
|
||||
if (!isSignedThinkingPart(part)) continue
|
||||
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend an existing thinking block (with its original signature) to a
|
||||
* message's parts array.
|
||||
*
|
||||
* We reuse the original Part verbatim instead of creating a new one, because
|
||||
* the Anthropic API validates the `signature` field against the thinking
|
||||
* content. Any synthetic block we create ourselves would fail that check.
|
||||
*/
|
||||
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
|
||||
if (!message.parts) {
|
||||
message.parts = []
|
||||
@@ -129,13 +144,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelIDValue = (lastUserMessage?.info as { modelID?: unknown } | undefined)?.modelID
|
||||
const modelID = typeof modelIDValue === "string" ? modelIDValue : ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
// Skip if there are no Anthropic-signed thinking blocks in history.
|
||||
// This is more reliable than checking model names — works for Claude,
|
||||
// GPT with thinking variants, or any future model. Crucially, GPT
|
||||
// reasoning blocks (type="reasoning", no signature) do NOT trigger this
|
||||
// hook — only real Anthropic thinking blocks do.
|
||||
if (!hasSignedThinkingBlocksInHistory(messages)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,12 +162,18 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find the most recent real thinking part (with valid signature) from
|
||||
// previous turns. If none exists we cannot safely inject a thinking
|
||||
// block — a synthetic block without a signature would cause the API
|
||||
// to reject the request with "Invalid `signature` in `thinking` block".
|
||||
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
||||
if (!previousThinkingPart) {
|
||||
continue
|
||||
}
|
||||
|
||||
prependThinkingBlock(msg, previousThinkingPart)
|
||||
if (previousThinkingPart) {
|
||||
prependThinkingBlock(msg, previousThinkingPart)
|
||||
}
|
||||
// If no real thinking part is available, skip injection entirely.
|
||||
// The downstream error (if any) is preferable to a guaranteed API
|
||||
// rejection caused by a signature-less synthetic thinking block.
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ session.idle
|
||||
## CONSTANTS
|
||||
|
||||
```typescript
|
||||
DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
|
||||
DEFAULT_SKIP_AGENTS = ["prometheus", "compaction", "plan"]
|
||||
CONTINUATION_COOLDOWN_MS = 30_000 // 30s between injections
|
||||
MAX_CONSECUTIVE_FAILURES = 5 // Then 5min pause (exponential backoff)
|
||||
FAILURE_RESET_WINDOW_MS = 5 * 60_000 // 5min window for failure reset
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system
|
||||
|
||||
export const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
|
||||
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction", "plan"]
|
||||
|
||||
export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
|
||||
|
||||
|
||||
@@ -47,4 +47,38 @@ describe("injectContinuation", () => {
|
||||
expect(capturedTools).toEqual({ question: false, bash: true })
|
||||
expect(capturedText).toContain(OMO_INTERNAL_INITIATOR_MARKER)
|
||||
})
|
||||
|
||||
test("skips injection when agent is plan (prevents Plan Mode infinite loop)", async () => {
|
||||
// given
|
||||
let injected = false
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }),
|
||||
promptAsync: async () => {
|
||||
injected = true
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const sessionStateStore = {
|
||||
getExistingState: () => ({ inFlight: false, lastInjectedAt: 0, consecutiveFailures: 0 }),
|
||||
}
|
||||
|
||||
// when
|
||||
await injectContinuation({
|
||||
ctx: ctx as never,
|
||||
sessionID: "ses_plan_skip",
|
||||
resolvedInfo: {
|
||||
agent: "plan",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
},
|
||||
sessionStateStore: sessionStateStore as never,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(injected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,6 +89,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
activePluginDispose = dispose
|
||||
|
||||
return {
|
||||
name: "oh-my-openagent",
|
||||
...pluginInterface,
|
||||
|
||||
"experimental.session.compacting": async (
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
name: "Builtin Sisyphus",
|
||||
prompt: "builtin prompt",
|
||||
mode: "primary",
|
||||
order: 1,
|
||||
}
|
||||
|
||||
const builtinOracleConfig: AgentConfig = {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { getAgentDisplayName } from "../shared/agent-display-names";
|
||||
|
||||
const CORE_AGENT_ORDER = [
|
||||
getAgentDisplayName("sisyphus"),
|
||||
getAgentDisplayName("hephaestus"),
|
||||
getAgentDisplayName("prometheus"),
|
||||
getAgentDisplayName("atlas"),
|
||||
] as const;
|
||||
const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [
|
||||
{ displayName: getAgentDisplayName("sisyphus"), order: 1 },
|
||||
{ displayName: getAgentDisplayName("hephaestus"), order: 2 },
|
||||
{ displayName: getAgentDisplayName("prometheus"), order: 3 },
|
||||
{ displayName: getAgentDisplayName("atlas"), order: 4 },
|
||||
];
|
||||
|
||||
function injectOrderField(
|
||||
agentConfig: unknown,
|
||||
order: number,
|
||||
): unknown {
|
||||
if (typeof agentConfig === "object" && agentConfig !== null) {
|
||||
return { ...agentConfig, order };
|
||||
}
|
||||
return agentConfig;
|
||||
}
|
||||
|
||||
export function reorderAgentsByPriority(
|
||||
agents: Record<string, unknown>,
|
||||
@@ -13,10 +23,10 @@ export function reorderAgentsByPriority(
|
||||
const ordered: Record<string, unknown> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const key of CORE_AGENT_ORDER) {
|
||||
if (Object.prototype.hasOwnProperty.call(agents, key)) {
|
||||
ordered[key] = agents[key];
|
||||
seen.add(key);
|
||||
for (const { displayName, order } of CORE_AGENT_ORDER) {
|
||||
if (Object.prototype.hasOwnProperty.call(agents, displayName)) {
|
||||
ordered[displayName] = injectOrderField(agents[displayName], order);
|
||||
seen.add(displayName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,14 @@ export async function buildPrometheusAgentConfig(params: {
|
||||
connectedProviders: connectedProviders ?? undefined,
|
||||
});
|
||||
|
||||
const configuredPrometheusModel =
|
||||
params.pluginPrometheusOverride?.model ?? categoryConfig?.model;
|
||||
|
||||
const modelResolution = resolveModelPipeline({
|
||||
intent: {
|
||||
uiSelectedModel: params.currentModel,
|
||||
userModel: params.pluginPrometheusOverride?.model ?? categoryConfig?.model,
|
||||
uiSelectedModel: configuredPrometheusModel ? undefined : params.currentModel,
|
||||
userModel: params.pluginPrometheusOverride?.model,
|
||||
categoryDefaultModel: categoryConfig?.model,
|
||||
},
|
||||
constraints: { availableModels },
|
||||
policy: {
|
||||
|
||||
@@ -32,7 +32,13 @@ export function createPluginInterface(args: {
|
||||
return {
|
||||
tool: tools,
|
||||
|
||||
"chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }),
|
||||
"chat.params": async (input: unknown, output: unknown) => {
|
||||
const handler = createChatParamsHandler({
|
||||
anthropicEffort: hooks.anthropicEffort,
|
||||
client: ctx.client,
|
||||
})
|
||||
await handler(input, output)
|
||||
},
|
||||
|
||||
"chat.headers": createChatHeadersHandler({ ctx }),
|
||||
|
||||
@@ -68,9 +74,5 @@ export function createPluginInterface(args: {
|
||||
ctx,
|
||||
hooks,
|
||||
}),
|
||||
|
||||
"tool.definition": async (input, output) => {
|
||||
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { createChatParamsHandler } from "./chat-params"
|
||||
import {
|
||||
clearSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
setSessionPromptParams,
|
||||
} from "../shared/session-prompt-params-state"
|
||||
|
||||
describe("createChatParamsHandler", () => {
|
||||
afterEach(() => {
|
||||
clearSessionPromptParams("ses_chat_params")
|
||||
})
|
||||
|
||||
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
|
||||
//#given
|
||||
let called = false
|
||||
@@ -35,4 +44,174 @@ describe("createChatParamsHandler", () => {
|
||||
//#then
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
test("passes the original mutable message object to chat.params hooks", async () => {
|
||||
//#given
|
||||
const handler = createChatParamsHandler({
|
||||
anthropicEffort: {
|
||||
"chat.params": async (input) => {
|
||||
input.message.variant = "high"
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const message = { variant: "max" }
|
||||
const input = {
|
||||
sessionID: "ses_chat_params",
|
||||
agent: { name: "sisyphus" },
|
||||
model: { providerID: "opencode", modelID: "claude-sonnet-4-6" },
|
||||
provider: { id: "opencode" },
|
||||
message,
|
||||
}
|
||||
|
||||
const output = {
|
||||
temperature: 0.1,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(message.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("applies stored prompt params for the session", async () => {
|
||||
//#given
|
||||
setSessionPromptParams("ses_chat_params", {
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
|
||||
const handler = createChatParamsHandler({
|
||||
anthropicEffort: null,
|
||||
})
|
||||
|
||||
const input = {
|
||||
sessionID: "ses_chat_params",
|
||||
agent: { name: "oracle" },
|
||||
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||
provider: { id: "openai" },
|
||||
message: {},
|
||||
}
|
||||
|
||||
const output = {
|
||||
temperature: 0.1,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: { existing: true },
|
||||
}
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(output).toEqual({
|
||||
topP: 0.7,
|
||||
topK: 1,
|
||||
options: {
|
||||
existing: true,
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
expect(getSessionPromptParams("ses_chat_params")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drops unsupported temperature and clamps maxTokens from bundled model capabilities", async () => {
|
||||
//#given
|
||||
setSessionPromptParams("ses_chat_params", {
|
||||
temperature: 0.7,
|
||||
options: {
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
})
|
||||
|
||||
const handler = createChatParamsHandler({
|
||||
anthropicEffort: null,
|
||||
})
|
||||
|
||||
const input = {
|
||||
sessionID: "ses_chat_params",
|
||||
agent: { name: "oracle" },
|
||||
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||
provider: { id: "openai" },
|
||||
message: {},
|
||||
}
|
||||
|
||||
const output = {
|
||||
temperature: 0.1,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(output).toEqual({
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: {
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drops unsupported reasoning settings from bundled model capabilities", async () => {
|
||||
//#given
|
||||
setSessionPromptParams("ses_chat_params", {
|
||||
temperature: 0.4,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "enabled", budgetTokens: 4096 },
|
||||
},
|
||||
})
|
||||
|
||||
const handler = createChatParamsHandler({
|
||||
anthropicEffort: null,
|
||||
})
|
||||
|
||||
const input = {
|
||||
sessionID: "ses_chat_params",
|
||||
agent: { name: "oracle" },
|
||||
model: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
provider: { id: "openai" },
|
||||
message: {},
|
||||
}
|
||||
|
||||
const output = {
|
||||
temperature: 0.1,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(output).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
options: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { normalizeSDKResponse } from "../shared/normalize-sdk-response"
|
||||
import { getSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||
import { getModelCapabilities, resolveCompatibleModelSettings } from "../shared"
|
||||
|
||||
export type ChatParamsInput = {
|
||||
sessionID: string
|
||||
agent: { name?: string }
|
||||
@@ -6,6 +10,10 @@ export type ChatParamsInput = {
|
||||
message: { variant?: string }
|
||||
}
|
||||
|
||||
type ChatParamsHookInput = ChatParamsInput & {
|
||||
rawMessage?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ChatParamsOutput = {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
@@ -17,7 +25,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
|
||||
function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
|
||||
if (!isRecord(raw)) return null
|
||||
|
||||
const sessionID = raw.sessionID
|
||||
@@ -43,7 +51,11 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
|
||||
if (!agentName) return null
|
||||
|
||||
const providerID = model.providerID
|
||||
const modelID = model.modelID
|
||||
const modelID = typeof model.modelID === "string"
|
||||
? model.modelID
|
||||
: typeof model.id === "string"
|
||||
? model.id
|
||||
: undefined
|
||||
const providerId = provider.id
|
||||
const variant = message.variant
|
||||
|
||||
@@ -56,7 +68,9 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
|
||||
agent: { name: agentName },
|
||||
model: { providerID, modelID },
|
||||
provider: { id: providerId },
|
||||
message: typeof variant === "string" ? { variant } : {},
|
||||
message,
|
||||
rawMessage: message,
|
||||
...(typeof variant === "string" ? {} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +83,100 @@ function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput {
|
||||
}
|
||||
|
||||
export function createChatParamsHandler(args: {
|
||||
anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise<void> } | null
|
||||
anthropicEffort: { "chat.params"?: (input: ChatParamsHookInput, output: ChatParamsOutput) => Promise<void> } | null
|
||||
client?: unknown
|
||||
}): (input: unknown, output: unknown) => Promise<void> {
|
||||
return async (input, output): Promise<void> => {
|
||||
const normalizedInput = buildChatParamsInput(input)
|
||||
if (!normalizedInput) return
|
||||
if (!isChatParamsOutput(output)) return
|
||||
|
||||
const storedPromptParams = getSessionPromptParams(normalizedInput.sessionID)
|
||||
if (storedPromptParams) {
|
||||
if (storedPromptParams.temperature !== undefined) {
|
||||
output.temperature = storedPromptParams.temperature
|
||||
}
|
||||
if (storedPromptParams.topP !== undefined) {
|
||||
output.topP = storedPromptParams.topP
|
||||
}
|
||||
if (storedPromptParams.options) {
|
||||
output.options = {
|
||||
...output.options,
|
||||
...storedPromptParams.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const capabilities = getModelCapabilities({
|
||||
providerID: normalizedInput.model.providerID,
|
||||
modelID: normalizedInput.model.modelID,
|
||||
})
|
||||
|
||||
const compatibility = resolveCompatibleModelSettings({
|
||||
providerID: normalizedInput.model.providerID,
|
||||
modelID: normalizedInput.model.modelID,
|
||||
desired: {
|
||||
variant: typeof normalizedInput.message.variant === "string"
|
||||
? normalizedInput.message.variant
|
||||
: undefined,
|
||||
reasoningEffort: typeof output.options.reasoningEffort === "string"
|
||||
? output.options.reasoningEffort
|
||||
: undefined,
|
||||
temperature: typeof output.temperature === "number" ? output.temperature : undefined,
|
||||
topP: typeof output.topP === "number" ? output.topP : undefined,
|
||||
maxTokens: typeof output.options.maxTokens === "number" ? output.options.maxTokens : undefined,
|
||||
thinking: isRecord(output.options.thinking) ? output.options.thinking : undefined,
|
||||
},
|
||||
capabilities,
|
||||
})
|
||||
|
||||
if (normalizedInput.rawMessage) {
|
||||
if (compatibility.variant !== undefined) {
|
||||
normalizedInput.rawMessage.variant = compatibility.variant
|
||||
} else {
|
||||
delete normalizedInput.rawMessage.variant
|
||||
}
|
||||
}
|
||||
normalizedInput.message = normalizedInput.rawMessage as { variant?: string }
|
||||
|
||||
if (compatibility.reasoningEffort !== undefined) {
|
||||
output.options.reasoningEffort = compatibility.reasoningEffort
|
||||
} else if ("reasoningEffort" in output.options) {
|
||||
delete output.options.reasoningEffort
|
||||
}
|
||||
|
||||
if ("temperature" in compatibility) {
|
||||
if (compatibility.temperature !== undefined) {
|
||||
output.temperature = compatibility.temperature
|
||||
} else {
|
||||
delete output.temperature
|
||||
}
|
||||
}
|
||||
|
||||
if ("topP" in compatibility) {
|
||||
if (compatibility.topP !== undefined) {
|
||||
output.topP = compatibility.topP
|
||||
} else {
|
||||
delete output.topP
|
||||
}
|
||||
}
|
||||
|
||||
if ("maxTokens" in compatibility) {
|
||||
if (compatibility.maxTokens !== undefined) {
|
||||
output.options.maxTokens = compatibility.maxTokens
|
||||
} else {
|
||||
delete output.options.maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinking" in compatibility) {
|
||||
if (compatibility.thinking !== undefined) {
|
||||
output.options.thinking = compatibility.thinking
|
||||
} else {
|
||||
delete output.options.thinking
|
||||
}
|
||||
}
|
||||
|
||||
await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { _resetForTesting, getSessionAgent, updateSessionAgent } from "../features/claude-code-session-state"
|
||||
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||
import { clearSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||
import { createEventHandler } from "./event"
|
||||
|
||||
function createMinimalEventHandler() {
|
||||
@@ -53,6 +54,8 @@ describe("createEventHandler compaction agent filtering", () => {
|
||||
_resetForTesting()
|
||||
clearSessionModel("ses_compaction_poisoning")
|
||||
clearSessionModel("ses_compaction_model_poisoning")
|
||||
clearSessionPromptParams("ses_compaction_poisoning")
|
||||
clearSessionPromptParams("ses_compaction_model_poisoning")
|
||||
})
|
||||
|
||||
it("does not overwrite the stored session agent with compaction", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createEventHandler } from "./event"
|
||||
import { createChatMessageHandler } from "./chat-message"
|
||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
||||
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||
|
||||
type EventInput = { event: { type: string; properties?: unknown } }
|
||||
|
||||
@@ -441,6 +442,45 @@ describe("createEventHandler - event forwarding", () => {
|
||||
expect(disconnectedSessions).toEqual([sessionID])
|
||||
expect(deletedSessions).toEqual([sessionID])
|
||||
})
|
||||
|
||||
it("clears stored prompt params on session.deleted", async () => {
|
||||
//#given
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {} as never,
|
||||
pluginConfig: {} as never,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
skillMcpManager: {
|
||||
disconnectSession: async () => {},
|
||||
},
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as never,
|
||||
hooks: {} as never,
|
||||
})
|
||||
const sessionID = "ses_prompt_params_deleted"
|
||||
setSessionPromptParams(sessionID, {
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: { reasoningEffort: "high" },
|
||||
})
|
||||
|
||||
//#when
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: sessionID } },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(getSessionPromptParams(sessionID)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createEventHandler - retry dedupe lifecycle", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
setSessionFallbackChain,
|
||||
setPendingModelFallback,
|
||||
} from "../hooks/model-fallback/hook";
|
||||
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
|
||||
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
||||
@@ -25,6 +25,7 @@ import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
||||
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { clearSessionPromptParams } from "../shared/session-prompt-params-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
|
||||
@@ -110,10 +111,10 @@ function applyUserConfiguredFallbackChain(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
): void {
|
||||
const agentKey = getAgentConfigKey(agentName);
|
||||
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
|
||||
if (configuredFallbackModels.length === 0) return;
|
||||
const rawFallbackModels = getRawFallbackModels(sessionID, agentKey, pluginConfig);
|
||||
if (!rawFallbackModels || rawFallbackModels.length === 0) return;
|
||||
|
||||
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
|
||||
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
setSessionFallbackChain(sessionID, fallbackChain);
|
||||
@@ -330,6 +331,7 @@ export function createEventHandler(args: {
|
||||
resetMessageCursor(sessionInfo.id);
|
||||
firstMessageVariantGate.clear(sessionInfo.id);
|
||||
clearSessionModel(sessionInfo.id);
|
||||
clearSessionPromptParams(sessionInfo.id);
|
||||
syncSubagentSessions.delete(sessionInfo.id);
|
||||
if (wasSyncSubagentSession) {
|
||||
subagentSessions.delete(sessionInfo.id);
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createSessionHooks(args: {
|
||||
showStartupToast: isHookEnabled("startup-toast"),
|
||||
isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true,
|
||||
autoUpdate: pluginConfig.auto_update ?? true,
|
||||
modelCapabilities: pluginConfig.model_capabilities,
|
||||
}))
|
||||
: null
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
createConnectedProvidersCacheStore,
|
||||
findProviderModelMetadata,
|
||||
} from "./connected-providers-cache"
|
||||
|
||||
let fakeUserCacheRoot = ""
|
||||
@@ -68,8 +69,14 @@ describe("updateConnectedProvidersCache", () => {
|
||||
expect(cache).not.toBeNull()
|
||||
expect(cache!.connected).toEqual(["openai", "anthropic"])
|
||||
expect(cache!.models).toEqual({
|
||||
openai: ["gpt-5.3-codex", "gpt-5.4"],
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6"],
|
||||
openai: [
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
],
|
||||
anthropic: [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,4 +181,86 @@ describe("updateConnectedProvidersCache", () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("findProviderModelMetadata returns rich cached metadata", async () => {
|
||||
//#given
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [
|
||||
{
|
||||
id: "openai",
|
||||
models: {
|
||||
"gpt-5.4": {
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
temperature: false,
|
||||
variants: {
|
||||
low: {},
|
||||
high: {},
|
||||
},
|
||||
limit: { output: 128000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
|
||||
//#when
|
||||
const result = findProviderModelMetadata("openai", "gpt-5.4", cache)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
temperature: false,
|
||||
variants: {
|
||||
low: {},
|
||||
high: {},
|
||||
},
|
||||
limit: { output: 128000 },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps normalized fallback ids when raw metadata id is not a string", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [
|
||||
{
|
||||
id: "openai",
|
||||
models: {
|
||||
"o3-mini": {
|
||||
id: 123,
|
||||
name: "o3-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
|
||||
expect(cache?.models.openai).toEqual([
|
||||
{ id: "o3-mini", name: "o3-mini" },
|
||||
])
|
||||
expect(findProviderModelMetadata("openai", "o3-mini", cache)).toEqual({
|
||||
id: "o3-mini",
|
||||
name: "o3-mini",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,20 +11,39 @@ interface ConnectedProvidersCache {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface ModelMetadata {
|
||||
export interface ModelMetadata {
|
||||
id: string
|
||||
provider?: string
|
||||
context?: number
|
||||
output?: number
|
||||
name?: string
|
||||
variants?: Record<string, unknown>
|
||||
limit?: {
|
||||
context?: number
|
||||
input?: number
|
||||
output?: number
|
||||
}
|
||||
modalities?: {
|
||||
input?: string[]
|
||||
output?: string[]
|
||||
}
|
||||
capabilities?: Record<string, unknown>
|
||||
reasoning?: boolean
|
||||
temperature?: boolean
|
||||
tool_call?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProviderModelsCache {
|
||||
export interface ProviderModelsCache {
|
||||
models: Record<string, string[] | ModelMetadata[]>
|
||||
connected: string[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export function createConnectedProvidersCacheStore(
|
||||
getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir
|
||||
) {
|
||||
@@ -119,7 +138,7 @@ export function createConnectedProvidersCacheStore(
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[]>; connected: string[] }): void {
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[] | ModelMetadata[]>; connected: string[] }): void {
|
||||
ensureCacheDir()
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
|
||||
@@ -164,14 +183,27 @@ export function createConnectedProvidersCacheStore(
|
||||
|
||||
writeConnectedProvidersCache(connected)
|
||||
|
||||
const modelsByProvider: Record<string, string[]> = {}
|
||||
const modelsByProvider: Record<string, ModelMetadata[]> = {}
|
||||
const allProviders = result.data?.all ?? []
|
||||
|
||||
for (const provider of allProviders) {
|
||||
if (provider.models) {
|
||||
const modelIds = Object.keys(provider.models)
|
||||
if (modelIds.length > 0) {
|
||||
modelsByProvider[provider.id] = modelIds
|
||||
const modelMetadata = Object.entries(provider.models).map(([modelID, rawMetadata]) => {
|
||||
if (!isRecord(rawMetadata)) {
|
||||
return { id: modelID }
|
||||
}
|
||||
|
||||
const normalizedID = typeof rawMetadata.id === "string"
|
||||
? rawMetadata.id
|
||||
: modelID
|
||||
|
||||
return {
|
||||
...rawMetadata,
|
||||
id: normalizedID,
|
||||
} satisfies ModelMetadata
|
||||
})
|
||||
if (modelMetadata.length > 0) {
|
||||
modelsByProvider[provider.id] = modelMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +232,32 @@ export function createConnectedProvidersCacheStore(
|
||||
}
|
||||
}
|
||||
|
||||
export function findProviderModelMetadata(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
cache: ProviderModelsCache | null = defaultConnectedProvidersCacheStore.readProviderModelsCache(),
|
||||
): ModelMetadata | undefined {
|
||||
const providerModels = cache?.models?.[providerID]
|
||||
if (!providerModels) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const entry of providerModels) {
|
||||
if (typeof entry === "string") {
|
||||
if (entry === modelID) {
|
||||
return { id: entry }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry?.id === modelID) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const defaultConnectedProvidersCacheStore = createConnectedProvidersCacheStore(
|
||||
() => dataPath.getOmoOpenCodeCacheDir()
|
||||
)
|
||||
|
||||
@@ -28,12 +28,29 @@ describe("resolveActualContextLimit", () => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
it("returns the default Anthropic limit when 1M mode is disabled despite a cached limit", () => {
|
||||
it("returns cached limit for Anthropic 4.6 models when 1M mode is disabled (GA support)", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 123456)
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4-6", 1_000_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-6", {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
|
||||
// then — models.dev reports 1M for GA models, resolver should respect it
|
||||
expect(actualLimit).toBe(1_000_000)
|
||||
})
|
||||
|
||||
it("returns default 200K for older Anthropic models even when cached limit is higher", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
|
||||
@@ -42,7 +59,38 @@ describe("resolveActualContextLimit", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
expect(actualLimit).toBe(200000)
|
||||
expect(actualLimit).toBe(200_000)
|
||||
})
|
||||
|
||||
it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(actualLimit).toBe(200_000)
|
||||
})
|
||||
|
||||
it("explicit 1M mode takes priority over cached limit", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
|
||||
anthropicContext1MEnabled: true,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
|
||||
// then — explicit 1M flag overrides cached 200K
|
||||
expect(actualLimit).toBe(1_000_000)
|
||||
})
|
||||
|
||||
it("treats Anthropics aliases as Anthropic providers", () => {
|
||||
@@ -61,6 +109,23 @@ describe("resolveActualContextLimit", () => {
|
||||
expect(actualLimit).toBe(200000)
|
||||
})
|
||||
|
||||
it("supports Anthropic 4.6 dot-version model IDs without explicit 1M mode", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4.6", 1_000_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4.6", {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(actualLimit).toBe(1_000_000)
|
||||
})
|
||||
|
||||
it("returns null for non-Anthropic providers without a cached limit", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import process from "node:process"
|
||||
|
||||
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const ANTHROPIC_NO_HEADER_GA_MODEL_IDS = new Set([
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4.6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4.6",
|
||||
])
|
||||
|
||||
export type ContextLimitModelCacheState = {
|
||||
anthropicContext1MEnabled: boolean
|
||||
@@ -20,13 +26,23 @@ function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState):
|
||||
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
|
||||
}
|
||||
|
||||
function isAnthropicNoHeaderGaModel(modelID: string): boolean {
|
||||
return ANTHROPIC_NO_HEADER_GA_MODEL_IDS.has(modelID.toLowerCase())
|
||||
}
|
||||
|
||||
export function resolveActualContextLimit(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
): number | null {
|
||||
if (isAnthropicProvider(providerID)) {
|
||||
return getAnthropicActualLimit(modelCacheState)
|
||||
const explicit1M = getAnthropicActualLimit(modelCacheState)
|
||||
if (explicit1M === 1_000_000) return explicit1M
|
||||
|
||||
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
|
||||
if (cachedLimit && isAnthropicNoHeaderGaModel(modelID)) return cachedLimit
|
||||
|
||||
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
|
||||
}
|
||||
|
||||
return modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`) ?? null
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import { accessSync, constants, mkdirSync } from "node:fs"
|
||||
|
||||
function resolveWritableDirectory(preferredDir: string, fallbackSuffix: string): string {
|
||||
try {
|
||||
mkdirSync(preferredDir, { recursive: true })
|
||||
accessSync(preferredDir, constants.W_OK)
|
||||
return preferredDir
|
||||
} catch {
|
||||
const fallbackDir = path.join(os.tmpdir(), fallbackSuffix)
|
||||
mkdirSync(fallbackDir, { recursive: true })
|
||||
return fallbackDir
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user-level data directory.
|
||||
@@ -10,7 +23,8 @@ import * as os from "node:os"
|
||||
* including Windows, so we match that behavior exactly.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
|
||||
const preferredDir = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
|
||||
return resolveWritableDirectory(preferredDir, "opencode-data")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +41,8 @@ export function getOpenCodeStorageDir(): string {
|
||||
* - All platforms: XDG_CACHE_HOME or ~/.cache
|
||||
*/
|
||||
export function getCacheDir(): string {
|
||||
return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache")
|
||||
const preferredDir = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache")
|
||||
return resolveWritableDirectory(preferredDir, "opencode-cache")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { buildFallbackChainFromModels, parseFallbackModelEntry } from "./fallback-chain-from-models"
|
||||
import { describe, test, it, expect } from "bun:test"
|
||||
import {
|
||||
parseFallbackModelEntry,
|
||||
parseFallbackModelObjectEntry,
|
||||
buildFallbackChainFromModels,
|
||||
findMostSpecificFallbackEntry,
|
||||
} from "./fallback-chain-from-models"
|
||||
import { flattenToFallbackModelStrings } from "./model-resolver"
|
||||
|
||||
// Upstream tests
|
||||
describe("fallback-chain-from-models", () => {
|
||||
test("parses provider/model entry with parenthesized variant", () => {
|
||||
//#given
|
||||
@@ -61,3 +68,330 @@ describe("fallback-chain-from-models", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Object-style entry tests
|
||||
describe("parseFallbackModelEntry (extended)", () => {
|
||||
it("parses provider/model string", () => {
|
||||
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6", undefined)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses model with parenthesized variant", () => {
|
||||
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6(high)", undefined)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses model with space variant", () => {
|
||||
const result = parseFallbackModelEntry("openai/gpt-5.4 xhigh", undefined)
|
||||
expect(result).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.4",
|
||||
variant: "xhigh",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses model with minimal space variant", () => {
|
||||
const result = parseFallbackModelEntry("openai/gpt-5.4 minimal", undefined)
|
||||
expect(result).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.4",
|
||||
variant: "minimal",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses context provider when no provider prefix", () => {
|
||||
const result = parseFallbackModelEntry("claude-sonnet-4-6", "anthropic")
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns undefined for empty string", () => {
|
||||
expect(parseFallbackModelEntry("", undefined)).toBeUndefined()
|
||||
expect(parseFallbackModelEntry(" ", undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseFallbackModelObjectEntry", () => {
|
||||
it("parses object with model only", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{ model: "anthropic/claude-sonnet-4-6" },
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses object with variant override", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
it("object variant overrides inline variant", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
it("carries reasoningEffort and temperature", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "high",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.5,
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.5,
|
||||
})
|
||||
})
|
||||
|
||||
it("carries thinking config", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
thinking: { type: "enabled", budgetTokens: 10000 },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["anthropic"],
|
||||
model: "claude-sonnet-4-6",
|
||||
thinking: { type: "enabled", budgetTokens: 10000 },
|
||||
})
|
||||
})
|
||||
|
||||
it("carries all optional fields", () => {
|
||||
const result = parseFallbackModelObjectEntry(
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "xhigh",
|
||||
reasoningEffort: "xhigh",
|
||||
temperature: 0.3,
|
||||
top_p: 0.9,
|
||||
maxTokens: 8192,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.4",
|
||||
variant: "xhigh",
|
||||
reasoningEffort: "xhigh",
|
||||
temperature: 0.3,
|
||||
top_p: 0.9,
|
||||
maxTokens: 8192,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFallbackChainFromModels (mixed)", () => {
|
||||
it("handles string input", () => {
|
||||
const result = buildFallbackChainFromModels("anthropic/claude-sonnet-4-6", undefined)
|
||||
expect(result).toEqual([
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
])
|
||||
})
|
||||
|
||||
it("handles string array", () => {
|
||||
const result = buildFallbackChainFromModels(
|
||||
["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
])
|
||||
})
|
||||
|
||||
it("handles mixed array of strings and objects", () => {
|
||||
const result = buildFallbackChainFromModels(
|
||||
[
|
||||
{ model: "anthropic/claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
||||
{ model: "openai/gpt-5.4", reasoningEffort: "xhigh" },
|
||||
"chutes/kimi-k2.5",
|
||||
{ model: "chutes/glm-5", temperature: 0.7 },
|
||||
"google/gemini-3-flash",
|
||||
],
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
||||
{ providers: ["openai"], model: "gpt-5.4", reasoningEffort: "xhigh" },
|
||||
{ providers: ["chutes"], model: "kimi-k2.5" },
|
||||
{ providers: ["chutes"], model: "glm-5", temperature: 0.7 },
|
||||
{ providers: ["google"], model: "gemini-3-flash" },
|
||||
])
|
||||
})
|
||||
|
||||
it("returns undefined for empty/undefined input", () => {
|
||||
expect(buildFallbackChainFromModels(undefined, undefined)).toBeUndefined()
|
||||
expect(buildFallbackChainFromModels([], undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("filters out invalid entries", () => {
|
||||
const result = buildFallbackChainFromModels(
|
||||
["", "anthropic/claude-sonnet-4-6", " "],
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("flattenToFallbackModelStrings", () => {
|
||||
it("returns undefined for undefined input", () => {
|
||||
expect(flattenToFallbackModelStrings(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("passes through plain strings", () => {
|
||||
expect(flattenToFallbackModelStrings(["anthropic/claude-sonnet-4-6"])).toEqual([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
])
|
||||
})
|
||||
|
||||
it("flattens object with explicit variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
||||
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||
})
|
||||
|
||||
it("preserves inline variant when no explicit variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "anthropic/claude-sonnet-4-6(high)" },
|
||||
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||
})
|
||||
|
||||
it("explicit variant overrides inline variant (no double-suffix)", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
||||
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||
})
|
||||
|
||||
it("explicit variant overrides space-suffix variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "openai/gpt-5.4 high", variant: "low" },
|
||||
])).toEqual(["openai/gpt-5.4(low)"])
|
||||
})
|
||||
|
||||
it("explicit variant overrides minimal space-suffix variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "openai/gpt-5.4 minimal", variant: "low" },
|
||||
])).toEqual(["openai/gpt-5.4(low)"])
|
||||
})
|
||||
|
||||
it("preserves trailing non-variant suffixes when adding explicit variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "openai/gpt-5.4 preview", variant: "low" },
|
||||
])).toEqual(["openai/gpt-5.4 preview(low)"])
|
||||
})
|
||||
|
||||
it("flattens object without variant", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
{ model: "openai/gpt-5.4" },
|
||||
])).toEqual(["openai/gpt-5.4"])
|
||||
})
|
||||
|
||||
it("handles mixed array", () => {
|
||||
expect(flattenToFallbackModelStrings([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
{ model: "openai/gpt-5.4", variant: "high" },
|
||||
{ model: "google/gemini-3-flash(low)" },
|
||||
])).toEqual([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-5.4(high)",
|
||||
"google/gemini-3-flash(low)",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("findMostSpecificFallbackEntry", () => {
|
||||
it("picks exact match over prefix match", () => {
|
||||
const chain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
||||
]
|
||||
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||
expect(result?.model).toBe("gpt-5.4-preview")
|
||||
})
|
||||
|
||||
it("returns prefix match when no exact match exists", () => {
|
||||
const chain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
]
|
||||
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||
expect(result?.model).toBe("gpt-5.4")
|
||||
})
|
||||
|
||||
it("returns undefined when no entry matches", () => {
|
||||
const chain = [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
]
|
||||
expect(findMostSpecificFallbackEntry("openai", "gpt-5.4", chain)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("sorts by matched prefix length, not insertion order", () => {
|
||||
// Both entries share the same provider so both match as prefixes;
|
||||
// the longer (more-specific) prefix must win regardless of array order.
|
||||
const chain = [
|
||||
{ providers: ["openai"], model: "gpt-5" },
|
||||
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
||||
]
|
||||
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview-2026", chain)
|
||||
expect(result?.model).toBe("gpt-5.4-preview")
|
||||
})
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const chain = [
|
||||
{ providers: ["OpenAI"], model: "GPT-5.4" },
|
||||
]
|
||||
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||
expect(result?.model).toBe("GPT-5.4")
|
||||
})
|
||||
|
||||
it("preserves variant and settings from matched entry", () => {
|
||||
const chain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4", variant: "high", temperature: 0.7 },
|
||||
{ providers: ["openai"], model: "gpt-5.4-preview", variant: "low", reasoningEffort: "medium" },
|
||||
]
|
||||
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||
expect(result).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.4-preview",
|
||||
variant: "low",
|
||||
reasoningEffort: "medium",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import type { FallbackModelObject } from "../config/schema/fallback-models"
|
||||
import { normalizeFallbackModels } from "./model-resolver"
|
||||
|
||||
const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
import { KNOWN_VARIANTS } from "./known-variants"
|
||||
|
||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||
const trimmedModel = rawModel.trim()
|
||||
@@ -61,8 +52,58 @@ export function parseFallbackModelEntry(
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFallbackModelObjectEntry(
|
||||
obj: FallbackModelObject,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry | undefined {
|
||||
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
|
||||
if (!base) return undefined
|
||||
|
||||
return {
|
||||
...base,
|
||||
variant: obj.variant ?? base.variant,
|
||||
reasoningEffort: obj.reasoningEffort,
|
||||
temperature: obj.temperature,
|
||||
top_p: obj.top_p,
|
||||
maxTokens: obj.maxTokens,
|
||||
thinking: obj.thinking,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
|
||||
* the resolved `provider/modelID`. Longest match wins so that e.g.
|
||||
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
|
||||
* the shorter `openai/gpt-5.4`.
|
||||
*/
|
||||
export function findMostSpecificFallbackEntry(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
chain: FallbackEntry[],
|
||||
): FallbackEntry | undefined {
|
||||
const resolved = `${providerID}/${modelID}`.toLowerCase()
|
||||
|
||||
// Collect entries whose provider/model is a prefix of the resolved model,
|
||||
// together with the length of the matching prefix (longest match wins).
|
||||
const matches: { entry: FallbackEntry; matchLen: number }[] = []
|
||||
for (const entry of chain) {
|
||||
for (const p of entry.providers) {
|
||||
const candidate = `${p}/${entry.model}`.toLowerCase()
|
||||
if (resolved.startsWith(candidate)) {
|
||||
matches.push({ entry, matchLen: candidate.length })
|
||||
break // one match per entry is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return undefined
|
||||
matches.sort((a, b) => b.matchLen - a.matchLen)
|
||||
return matches[0].entry
|
||||
}
|
||||
|
||||
export function buildFallbackChainFromModels(
|
||||
fallbackModels: string | string[] | undefined,
|
||||
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry[] | undefined {
|
||||
@@ -70,7 +111,12 @@ export function buildFallbackChainFromModels(
|
||||
if (!normalized || normalized.length === 0) return undefined
|
||||
|
||||
const parsed = normalized
|
||||
.map((model) => parseFallbackModelEntry(model, contextProviderID, defaultProviderID))
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
|
||||
}
|
||||
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
|
||||
})
|
||||
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||
|
||||
if (parsed.length === 0) return undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user