Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
3c39a0f10b fix(publish): restore canonical repo guard
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-25 01:21:58 +09:00
153 changed files with 686 additions and 48915 deletions

View File

@@ -1,46 +0,0 @@
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

View File

@@ -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.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. |
| **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. |
---
@@ -131,8 +131,7 @@ 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.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. |
| **MiniMax M2.5** | Fast and smart. Good for utility tasks and search/retrieval. |
### OpenCode Go
@@ -144,11 +143,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.7** | Ultra-cheap, fast responses. Used by Librarian, Explore, Atlas, Sisyphus-Junior for utility work. |
| **opencode-go/minimax-m2.5** | Ultra-cheap, fast responses. Used by Librarian, Explore 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 M2.7-highspeed, 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 Free, Big Pickle) or GPT alternatives.
**Go-Only Scenarios:**
@@ -156,7 +155,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.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 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 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.
@@ -172,7 +171,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.7 → GPT-5-Nano |
| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → 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 |

View File

@@ -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.7 models
- OpenCode Go is a $10/month subscription providing access to GLM-5, Kimi K2.5, and MiniMax M2.5 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.7-highspeed`.
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`.
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.7-highspeed` / `opencode/big-pickle` |
| **Librarian** | `opencode/minimax-m2.5-free` / `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.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. |
| **MiniMax M2.5** | venice | Fast and smart. Good for utility tasks. |
| **MiniMax M2.5 Free** | opencode | Free-tier MiniMax. Fast for search/retrieval. |
**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.7 Highspeed** | opencode | Very fast | Ultra-fast MiniMax variant. Smart for its speed class. |
| **MiniMax M2.5 (Free)** | opencode, venice | Fast | 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 | 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. |
| **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. |
| **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

View File

@@ -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.7 / M2.7-highspeed — fast and smart for utility tasks
- MiniMax M2.5 — 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.

View File

@@ -1,33 +0,0 @@
# 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.

View File

@@ -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** | `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` |
| **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` |
| **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.7``gpt-5-nano` |
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6``gpt-5.3-codex``gemini-3-flash` `minimax-m2.7` |
| **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` |
| **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` `minimax-m2.7` |
| **writing** | `gemini-3-flash` | `gemini-3-flash``claude-sonnet-4-6` |
Run `bunx oh-my-openagent doctor --verbose` to see effective model resolution for your config.

View File

@@ -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** | `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`. |
| **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`. |
| **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

View File

@@ -1,86 +0,0 @@
# 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**

View File

@@ -1,164 +0,0 @@
# 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.

View File

@@ -25,12 +25,10 @@
"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"
},

View File

@@ -1,13 +0,0 @@
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`)

View File

@@ -2303,30 +2303,6 @@
"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
}
]
}

View File

@@ -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** | minimax-m2.7 | 0.1 | subagent | minimax-m2.7-highspeedclaude-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 |
| **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 |
| **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 |

View File

@@ -44,10 +44,6 @@ 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)
}

View File

@@ -8,7 +8,6 @@ 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>
@@ -76,13 +75,7 @@ export function collectPendingBuiltinAgents(input: {
availableModels,
systemDefaultModel,
})
if (!resolution) {
if (override?.model) {
log("[agent-registration] User-configured model could not be resolved, falling back", {
agent: agentName,
configuredModel: override.model,
})
}
if (!resolution && isFirstRunNoCache && !override?.model) {
resolution = getFirstFallbackModel(requirement)
}
if (!resolution) continue

View File

@@ -35,11 +35,6 @@ 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.

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel, isGpt5_4Model, isMiniMaxModel } from "./types";
import { isGptModel, isGeminiModel, isGpt5_4Model } from "./types";
describe("isGpt5_4Model", () => {
test("detects gpt-5.4 models", () => {
@@ -79,28 +79,6 @@ 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);

View File

@@ -91,11 +91,6 @@ 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;
@@ -128,7 +123,7 @@ export type AgentName = BuiltinAgentName;
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string;
variant?: string;
fallback_models?: string | (string | import("../config/schema/fallback-models").FallbackModelObject)[];
fallback_models?: string | string[];
};
export type AgentOverrides = Partial<

View File

@@ -3,7 +3,6 @@ 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"
@@ -177,21 +176,6 @@ 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")

View File

@@ -4,10 +4,6 @@ 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
@@ -41,7 +37,7 @@ export function buildModelResolutionDetails(options: {
agent.effectiveModel,
getEffectiveVariant(agent.name, agent.requirement, options.config)
)
details.push(` ${marker} ${agent.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(agent.capabilityDiagnostics?.resolutionMode)}]`)
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
@@ -51,7 +47,7 @@ export function buildModelResolutionDetails(options: {
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
)
details.push(` ${marker} ${category.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(category.capabilityDiagnostics?.resolutionMode)}]`)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")

View File

@@ -1,4 +1,3 @@
import type { ModelCapabilitiesDiagnostics } from "../../../shared/model-capabilities"
import type { ModelRequirement } from "../../../shared/model-requirements"
export interface AgentResolutionInfo {
@@ -8,7 +7,6 @@ export interface AgentResolutionInfo {
userVariant?: string
effectiveModel: string
effectiveResolution: string
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
}
export interface CategoryResolutionInfo {
@@ -18,7 +16,6 @@ export interface CategoryResolutionInfo {
userVariant?: string
effectiveModel: string
effectiveResolution: string
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
}
export interface ModelResolutionInfo {

View File

@@ -129,19 +129,6 @@ describe("model-resolution check", () => {
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
expect(visual!.userVariant).toBe("high")
})
it("attaches snapshot-backed capability diagnostics for built-in models", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
const info = getModelResolutionInfoWithOverrides({})
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.capabilityDiagnostics).toMatchObject({
resolutionMode: "snapshot-backed",
snapshot: { source: "bundled-snapshot" },
})
})
})
describe("checkModelResolution", () => {
@@ -175,23 +162,6 @@ 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")
})
})

View File

@@ -1,5 +1,4 @@
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"
@@ -8,36 +7,16 @@ import { buildModelResolutionDetails } from "./model-resolution-details"
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
function parseProviderModel(value: string): { providerID: string; modelID: string } | null {
const slashIndex = value.indexOf("/")
if (slashIndex <= 0 || slashIndex === value.length - 1) {
return null
}
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({
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}))
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
@@ -45,16 +24,6 @@ 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 }
}
@@ -62,60 +31,34 @@ 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 attachCapabilityDiagnostics({
return {
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 attachCapabilityDiagnostics({
return {
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)
@@ -132,8 +75,6 @@ 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

View File

@@ -1,10 +1,9 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, rmSync, 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"
@@ -105,31 +104,6 @@ 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", () => {

View File

@@ -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,11 +36,6 @@ 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
@@ -60,13 +55,12 @@ function normalizeVersion(value: string | undefined): string | null {
export function getLoadedPluginVersion(): LoadedVersionInfo {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
const configDir = resolveExistingDir(configPaths.configDir)
const cacheDir = resolveExistingDir(resolveOpenCodeCacheDir())
const cacheDir = resolveOpenCodeCacheDir()
const candidates = [
{
cacheDir: configDir,
cachePackagePath: join(configDir, "package.json"),
installedPackagePath: join(configDir, "node_modules", PACKAGE_NAME, "package.json"),
cacheDir: configPaths.configDir,
cachePackagePath: configPaths.packageJson,
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
},
{
cacheDir,

View File

@@ -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.7" }
agents[role] = { model: "opencode-go/minimax-m2.5" }
} 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.7" }
agents[role] = { model: "opencode-go/minimax-m2.5" }
} else if (avail.copilot) {
agents[role] = { model: "github-copilot/gpt-5-mini" }
} else {

View File

@@ -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.7" })
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.7" })
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.5" })
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.5" })
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" })
})
})

View File

@@ -1,114 +0,0 @@
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")
})
})

View File

@@ -1,51 +0,0 @@
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
}
}

View File

@@ -19,6 +19,5 @@ export type {
SisyphusConfig,
SisyphusTasksConfig,
RuntimeFallbackConfig,
ModelCapabilitiesConfig,
FallbackModels,
} from "./schema"

View File

@@ -147,37 +147,6 @@ 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", () => {
@@ -402,26 +371,6 @@ 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 }

View File

@@ -13,7 +13,6 @@ 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"

View File

@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
})
.optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */

View File

@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
budgetTokens: z.number().optional(),
})
.optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["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(),

View File

@@ -1,25 +1,5 @@
import { z } from "zod"
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 const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
export type FallbackModels = z.infer<typeof FallbackModelsSchema>

View File

@@ -1,10 +0,0 @@
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>

View File

@@ -13,7 +13,6 @@ 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"
@@ -57,7 +56,6 @@ 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(),

View File

@@ -1,4 +1,4 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { describe, test, expect, mock, beforeEach } from "bun:test"
mock.module("../../shared", () => ({
log: mock(() => {}),
@@ -19,8 +19,6 @@ 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"
@@ -82,14 +80,8 @@ 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", () => {
@@ -275,24 +267,4 @@ 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")
})
})
})

View File

@@ -35,14 +35,10 @@ 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
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
return true
}
return preferredProvider ? connectedSet.has(preferredProvider) : false
return entry.providers.some((p) => connectedSet.has(p.toLowerCase()))
}
let selectedAttemptCount = attemptCount

View File

@@ -1,5 +1,3 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import {
createToolCallSignature,
@@ -21,7 +19,7 @@ function buildWindow(
}
function buildWindowWithInputs(
calls: Array<{ tool: string; input?: Record<string, unknown> | null }>,
calls: Array<{ tool: string; input?: Record<string, unknown> }>,
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
) {
const settings = resolveCircuitBreakerSettings(override)
@@ -150,12 +148,7 @@ describe("loop-detector", () => {
describe("#given the same tool is called consecutively", () => {
test("#when evaluated #then it triggers", () => {
const window = buildWindowWithInputs(
Array.from({ length: 20 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const window = buildWindow(Array.from({ length: 20 }, () => "read"))
const result = detectRepetitiveToolUse(window)
@@ -183,12 +176,7 @@ describe("loop-detector", () => {
describe("#given threshold boundary", () => {
test("#when below threshold #then it does not trigger", () => {
const belowThresholdWindow = buildWindowWithInputs(
Array.from({ length: 19 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const belowThresholdWindow = buildWindow(Array.from({ length: 19 }, () => "read"))
const result = detectRepetitiveToolUse(belowThresholdWindow)
@@ -196,12 +184,7 @@ describe("loop-detector", () => {
})
test("#when equal to threshold #then it triggers", () => {
const atThresholdWindow = buildWindowWithInputs(
Array.from({ length: 20 }, () => ({
tool: "read",
input: { filePath: "/src/same.ts" },
}))
)
const atThresholdWindow = buildWindow(Array.from({ length: 20 }, () => "read"))
const result = detectRepetitiveToolUse(atThresholdWindow)
@@ -241,22 +224,16 @@ describe("loop-detector", () => {
})
})
describe("#given tool calls with undefined input", () => {
test("#when evaluated #then it does not trigger", () => {
describe("#given tool calls with no input", () => {
test("#when evaluated #then it triggers", () => {
const calls = Array.from({ length: 20 }, () => ({ tool: "read" }))
const window = buildWindowWithInputs(calls)
const result = detectRepetitiveToolUse(window)
expect(result).toEqual({ triggered: false })
})
})
describe("#given tool calls with null input", () => {
test("#when evaluated #then it does not trigger", () => {
const calls = Array.from({ length: 20 }, () => ({ tool: "read", input: null }))
const window = buildWindowWithInputs(calls)
const result = detectRepetitiveToolUse(window)
expect(result).toEqual({ triggered: false })
expect(result).toEqual({
triggered: true,
toolName: "read",
repeatedCount: 20,
})
})
})
})

View File

@@ -36,14 +36,6 @@ export function recordToolCall(
settings: CircuitBreakerSettings,
toolInput?: Record<string, unknown> | null
): ToolCallWindow {
if (toolInput === undefined || toolInput === null) {
return {
lastSignature: `${toolName}::__unknown-input__`,
consecutiveCount: 1,
threshold: settings.consecutiveThreshold,
}
}
const signature = createToolCallSignature(toolName, toolInput)
if (window && window.lastSignature === signature) {

View File

@@ -1,5 +1,3 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { tmpdir } from "node:os"
@@ -40,8 +38,8 @@ async function flushAsyncWork() {
}
describe("BackgroundManager circuit breaker", () => {
describe("#given flat-format tool events have no state.input", () => {
test("#when 20 consecutive read events arrive #then the task keeps running", async () => {
describe("#given the same tool is called consecutively", () => {
test("#when consecutive tool events arrive #then the task is cancelled", async () => {
const manager = createManager({
circuitBreaker: {
consecutiveThreshold: 20,
@@ -73,8 +71,8 @@ describe("BackgroundManager circuit breaker", () => {
await flushAsyncWork()
expect(task.status).toBe("running")
expect(task.progress?.toolCalls).toBe(20)
expect(task.status).toBe("cancelled")
expect(task.error).toContain("read 20 consecutive times")
})
})
@@ -128,7 +126,7 @@ describe("BackgroundManager circuit breaker", () => {
})
describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
test("#when repeated flat-format tool events reach maxToolCalls #then the backstop still cancels the task", async () => {
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => {
const manager = createManager({
maxToolCalls: 3,
circuitBreaker: {
@@ -152,10 +150,10 @@ describe("BackgroundManager circuit breaker", () => {
}
getTaskMap(manager).set(task.id, task)
for (let i = 0; i < 3; i++) {
for (const toolName of ["read", "grep", "edit"]) {
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: "read" },
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
})
}

View File

@@ -1,6 +1,5 @@
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"
@@ -1637,9 +1636,6 @@ describe("BackgroundManager.resume model persistence", () => {
})
afterEach(() => {
clearSessionPromptParams("session-1")
clearSessionPromptParams("session-advanced")
clearSessionPromptParams("session-2")
manager.shutdown()
})
@@ -1672,61 +1668,7 @@ 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(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,
},
})
expect("agent" in promptCalls[0].body).toBe(false)
})
test("should NOT pass model when task has no model (backward compatibility)", async () => {
@@ -1890,7 +1832,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(task2.status).toBe("pending")
})
test("should keep agent when launch has model and keep agent without model", async () => {
test("should omit agent when launch has model and keep agent without model", async () => {
// given
const promptBodies: Array<Record<string, unknown>> = []
let resolveFirstPromptStarted: (() => void) | undefined
@@ -1952,7 +1894,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(promptBodies[0].agent).toBe("test-agent")
expect("agent" in promptBodies[0]).toBe(false)
expect(promptBodies[1].agent).toBe("test-agent")
expect("model" in promptBodies[1]).toBe(false)
})
@@ -2484,133 +2426,6 @@ 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", () => {
@@ -4937,53 +4752,6 @@ 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
@@ -5028,48 +4796,4 @@ 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()
})
})

View File

@@ -15,7 +15,6 @@ 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"
@@ -505,24 +504,20 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
// Temperature/topP and provider-specific options are applied through chat.params.
// Include model if caller provided one (e.g., from Sisyphus category configs)
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
const launchModel = input.model
? {
providerID: input.model.providerID,
modelID: input.model.modelID,
}
? { 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: {
agent: input.agent,
// 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 }),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
@@ -550,9 +545,6 @@ 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
@@ -792,23 +784,19 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
// Include model if task has one (preserved from original launch with category config)
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
const resumeModel = existingTask.model
? {
providerID: existingTask.model.providerID,
modelID: existingTask.model.modelID,
}
? { 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: {
agent: existingTask.agent,
// 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 }),
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
@@ -829,9 +817,6 @@ 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) {
@@ -1028,9 +1013,6 @@ 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) {
@@ -1363,12 +1345,8 @@ 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
}
@@ -1489,10 +1467,6 @@ 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
@@ -1731,9 +1705,6 @@ 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)

View File

@@ -1,120 +1,33 @@
import { describe, test, expect, mock, afterEach } from "bun:test"
import { describe, test, expect } 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 fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
})
test("passes promoted fallback model settings through supported prompt channels", async () => {
describe("background-agent spawner.startTask", () => {
test("applies explicit child session permission rules when creating child session", async () => {
//#given
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 createCalls: any[] = []
const parentPermission = [
{ permission: "question", action: "allow" as const, pattern: "*" },
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
]
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async () => ({ data: { id: "ses_child" } }),
promptAsync: async (args?: any) => {
promptCalls.push(args)
return {}
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
create: async (args?: any) => {
createCalls.push(args)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
},
}
const task = createTask({
description: "Test task",
prompt: "Do work",
agent: "sisyphus-junior",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
})
const item = {
@@ -128,6 +41,9 @@ describe("background-agent spawner fallback model promotion", () => {
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
},
}
@@ -143,12 +59,9 @@ describe("background-agent spawner fallback model promotion", () => {
await startTask(item as any, ctx as any)
//#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")
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
})
})

View File

@@ -2,7 +2,6 @@ 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"
@@ -129,19 +128,16 @@ 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: {
agent: input.agent,
// 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 }),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
@@ -219,19 +215,16 @@ 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: {
agent: task.agent,
// 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 }),
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {

View File

@@ -1,5 +1,4 @@
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 =
@@ -44,7 +43,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: DelegatedModelConfig
model?: { providerID: string; modelID: string; variant?: string }
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
/** Number of fallback retry attempts made */
@@ -77,7 +76,7 @@ export interface LaunchInput {
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
model?: DelegatedModelConfig
model?: { providerID: string; modelID: string; variant?: string }
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
isUnstableAgent?: boolean

View File

@@ -1,104 +0,0 @@
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")
})
})

View File

@@ -1,7 +1,6 @@
import { existsSync, readFileSync } from "fs"
import { homedir } from "os"
import { basename, join } from "path"
import { fileURLToPath } from "url"
import { join } from "path"
import { log } from "../../shared/logger"
import type {
InstalledPluginsDatabase,
@@ -80,34 +79,8 @@ function loadPluginManifest(installPath: string): PluginManifest | null {
}
function derivePluginNameFromKey(pluginKey: string): string {
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
const atIndex = pluginKey.indexOf("@")
return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey
}
function isPluginEnabled(

View File

@@ -1,112 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import { afterEach, describe, expect, it } 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
if (serveSpy) {
serveSpy.mockRestore()
serveSpy = null
}
activeServer = null
if (canBindRealSockets) {
await Bun.sleep(10)
}
// Allow time for port to be released before next test
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://${HOSTNAME}:${server.port}/oauth/callback?code=test-code&state=test-state`
const callbackUrl = `http://127.0.0.1:${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(),
request(callbackUrl),
nativeFetch(callbackUrl)
])
// then
expect(result).toEqual({ code: "test-code", state: "test-state" })
expect(response.status).toBe(200)
const html = await response.text()
@@ -114,19 +46,25 @@ describe("startCallbackServer", () => {
})
it("returns 404 for non-callback routes", async () => {
// given
server = await startCallbackServer()
const response = await request(`http://${HOSTNAME}:${server.port}/other`)
// when
const response = await nativeFetch(`http://127.0.0.1:${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((error: Error) => error)
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?state=s`)
// when
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
// then
expect(response.status).toBe(400)
const error = await callbackRejection
expect(error).toBeInstanceOf(Error)
@@ -134,11 +72,14 @@ describe("startCallbackServer", () => {
})
it("returns 400 and rejects when state is missing", async () => {
// given
server = await startCallbackServer()
const callbackRejection = server.waitForCallback().catch((error: Error) => error)
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?code=c`)
// when
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
// then
expect(response.status).toBe(400)
const error = await callbackRejection
expect(error).toBeInstanceOf(Error)
@@ -146,15 +87,18 @@ 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 request(`http://${HOSTNAME}:${port}/oauth/callback?code=c&state=s`)
expect.unreachable("request should fail after close")
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
expect(true).toBe(false)
} catch (error) {
expect(error).toBeDefined()
}

View File

@@ -39,7 +39,7 @@ export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promi
}
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
const requestedPort = await findAvailablePort(startPort).catch(() => 0)
const port = await findAvailablePort(startPort)
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: requestedPort,
port,
hostname: "127.0.0.1",
fetch(request: Request): Response {
const url = new URL(request.url)
@@ -93,10 +93,9 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
})
},
})
const activePort = server.port ?? requestedPort
return {
port: activePort,
port,
waitForCallback: () => callbackPromise,
close: () => {
clearTimeout(timeoutId)

View File

@@ -90,69 +90,6 @@ 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"

View File

@@ -36,16 +36,28 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<stri
return { ok: true, json }
}
function parseMetadataFields(json: Record<string, unknown>, resource: string): OAuthServerMetadata {
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})`)
}
const authorizationEndpoint = parseHttpsUrl(
readStringField(json, "authorization_endpoint"),
readStringField(metadata.json, "authorization_endpoint"),
"authorization_endpoint"
).toString()
const tokenEndpoint = parseHttpsUrl(
readStringField(json, "token_endpoint"),
readStringField(metadata.json, "token_endpoint"),
"token_endpoint"
).toString()
const registrationEndpointValue = json.registration_endpoint
const registrationEndpointValue = metadata.json.registration_endpoint
const registrationEndpoint =
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
@@ -59,29 +71,6 @@ function parseMetadataFields(json: Record<string, unknown>, resource: string): O
}
}
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 []

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { log, normalizeModelID } from "../../shared"
const OPUS_PATTERN = /claude-.*opus/i
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/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 isOpusModel(modelID: string): boolean {
function isOpus46(modelID: string): boolean {
const normalized = normalizeModelID(modelID)
return OPUS_PATTERN.test(normalized)
return OPUS_4_6_PATTERN.test(normalized)
}
interface ChatParamsInput {
@@ -28,20 +28,6 @@ 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 (
@@ -52,27 +38,15 @@ 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
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,
})
}
output.options.effort = "max"
log("anthropic-effort: injected effort=max", {
sessionID: input.sessionID,
provider: model.providerID,
model: model.modelID,
})
},
}
}

View File

@@ -45,99 +45,186 @@ function createMockParams(overrides: {
}
describe("createAnthropicEffortHook", () => {
describe("opus family with variant max", () => {
it("injects effort max for anthropic opus-4-6", async () => {
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
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("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
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
//#given github-copilot provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-opus-4-5",
providerID: "github-copilot",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then max should still be treated as valid for opus family
//#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
expect(output.options.effort).toBe("max")
expect(input.message.variant).toBe("max")
})
})
describe("skip conditions", () => {
it("does nothing when variant is not max", async () => {
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)
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("does nothing when variant is undefined", async () => {
it("should NOT inject effort when variant is undefined", async () => {
//#given opus-4-6 with no variant
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: undefined })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should clamp effort to high for non-opus claude model with variant max", async () => {
//#given claude-sonnet-4-6 (not opus) with variant max
it("should NOT inject effort for non-opus model", async () => {
//#given claude-sonnet-4-6 (not opus)
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6" })
const { input, output } = createMockParams({
modelID: "claude-sonnet-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be clamped to high (not max)
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("does nothing for non-claude providers/models", async () => {
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
//#given openai provider with gpt model
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("existing options", () => {
it("does not overwrite existing effort", async () => {
describe("preserves existing options", () => {
it("should NOT overwrite existing effort if already set", async () => {
//#given options already have effort set
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,
})
})
})
})

View File

@@ -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, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { _resetForTesting, 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(overrides: Partial<BoulderState> = {}): void {
function writeIncompleteBoulder(): void {
const planPath = join(testDirectory, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -25,7 +25,6 @@ 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)
@@ -104,7 +103,6 @@ describe("atlas hook idle-event session lineage", () => {
writeIncompleteBoulder()
subagentSessions.add(subagentSessionID)
setSessionAgent(subagentSessionID, "atlas")
const hook = createHook({
[subagentSessionID]: intermediateParentSessionID,
@@ -121,63 +119,4 @@ 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)
})
})

View File

@@ -5,8 +5,6 @@ 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"
@@ -138,23 +136,6 @@ 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()

View File

@@ -1282,7 +1282,6 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
subagentSessions.add(subagentSessionID)
updateSessionAgent(subagentSessionID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)

View File

@@ -1,14 +1,8 @@
const { describe, expect, mock, test } = require("bun:test")
mock.module("../../shared/opencode-message-dir", () => ({
mock.module("../../shared", () => ({
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,
}))

View File

@@ -3,7 +3,6 @@ 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 () => {})
@@ -23,10 +22,6 @@ mock.module("./hook/connected-providers-status", () => ({
mockUpdateAndShowConnectedProvidersCacheStatus,
}))
mock.module("./hook/model-capabilities-status", () => ({
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
}))
mock.module("./hook/startup-toasts", () => ({
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
@@ -83,7 +78,6 @@ beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockRefreshModelCapabilitiesOnStartup.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
@@ -118,7 +112,6 @@ 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()
@@ -136,7 +129,6 @@ 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)
@@ -154,7 +146,6 @@ 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()
@@ -174,7 +165,6 @@ 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)
@@ -193,7 +183,6 @@ 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()
@@ -216,7 +205,6 @@ 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()

View File

@@ -5,17 +5,11 @@ 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,
modelCapabilities,
} = options
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"
const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => {
@@ -49,7 +43,6 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
await showConfigErrorsIfAny(ctx)
await updateAndShowConnectedProvidersCacheStatus(ctx)
await refreshModelCapabilitiesOnStartup(modelCapabilities)
await showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {

View File

@@ -1,37 +0,0 @@
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)
}
}
}

View File

@@ -1,5 +1,3 @@
import type { ModelCapabilitiesConfig } from "../../config/schema/model-capabilities"
export interface NpmDistTags {
latest: string
[key: string]: string
@@ -28,5 +26,4 @@ export interface AutoUpdateCheckerOptions {
showStartupToast?: boolean
isSisyphusEnabled?: boolean
autoUpdate?: boolean
modelCapabilities?: ModelCapabilitiesConfig
}

View File

@@ -135,96 +135,9 @@ describe("context-window-monitor modelContextLimitsCache", () => {
})
})
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 () => {
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 () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
@@ -233,7 +146,7 @@ describe("context-window-monitor modelContextLimitsCache", () => {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_older_model_ignores_cached_limit"
const sessionID = "ses_anthropic_default_overrides_cached_limit"
await hook.event({
event: {
@@ -263,6 +176,8 @@ 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")
})
})
})

View File

@@ -293,6 +293,8 @@ NOW.
</ultrawork-mode>
---
`
export function getDefaultUltraworkMessage(): string {

View File

@@ -283,6 +283,8 @@ NOW.
</ultrawork-mode>
---
`
export function getGeminiUltraworkMessage(): string {

View File

@@ -166,6 +166,8 @@ A task is complete when:
</ultrawork-mode>
---
`;
export function getGptUltraworkMessage(): string {

View File

@@ -136,5 +136,7 @@ ${ULTRAWORK_PLANNER_SECTION}
</ultrawork-mode>
---
`
}

View File

@@ -3,24 +3,6 @@ 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
@@ -49,10 +31,6 @@ mock.module("../../shared/provider-model-id-transform", () => ({
transformModelForProvider: transformModelForProviderMock,
}))
mock.module("../../shared/model-error-classifier", () => ({
selectFallbackProvider: selectFallbackProviderMock,
}))
import {
clearPendingModelFallback,
createModelFallbackHook,
@@ -66,7 +44,6 @@ describe("model fallback hook", () => {
readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockClear()
readProviderModelsCacheMock.mockClear()
selectFallbackProviderMock.mockClear()
clearPendingModelFallback("ses_model_fallback_main")
clearPendingModelFallback("ses_model_fallback_ghcp")
@@ -278,50 +255,6 @@ 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 }> = []

View File

@@ -130,21 +130,14 @@ export function getNextFallback(
const providerModelsCache = readProviderModelsCache()
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
const connectedSet = connectedProviders
? new Set(connectedProviders.map((provider) => provider.toLowerCase()))
: null
const connectedSet = connectedProviders ? new Set(connectedProviders) : 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.
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
return true
}
const preferredProvider = state.providerID.toLowerCase()
return connectedSet.has(preferredProvider)
return entry.providers.some((p) => connectedSet.has(p))
}
while (state.attemptCount < fallbackChain.length) {
@@ -274,13 +267,3 @@ 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()
}

View File

@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
import { log, buildEnvPrefix } from "../../shared"
import { log, buildEnvPrefix, detectShellType } from "../../shared"
export * from "./constants"
export * from "./detector"
@@ -52,7 +52,9 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
// The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected
// for git commands to prevent interactive prompts.
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix")
// Detect the current shell type for proper env var syntax (export for bash/zsh, setenv for csh/tcsh, etc.)
const shellType = detectShellType()
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
// Check if the command already starts with the prefix to avoid stacking.
// This maintains the non-interactive behavior and makes the operation idempotent.

View File

@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
*/
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
enabled: false,
retry_on_errors: [402, 429, 500, 502, 503, 504],
retry_on_errors: [429, 500, 502, 503, 504],
max_fallback_attempts: 3,
cooldown_seconds: 60,
timeout_seconds: 30,
@@ -37,11 +37,6 @@ 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|$)/,

View File

@@ -31,20 +31,6 @@ 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 = {
@@ -180,100 +166,3 @@ 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)
})
})

View File

@@ -21,13 +21,6 @@ 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 {
@@ -119,21 +112,6 @@ 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
}
@@ -167,7 +145,7 @@ export function extractAutoRetrySignal(info: Record<string, unknown> | undefined
const combined = candidates.join("\n")
if (!combined) return undefined
const isAutoRetry = AUTO_RETRY_PATTERNS.some((test) => test(combined))
const isAutoRetry = AUTO_RETRY_PATTERNS.every((test) => test(combined))
if (isAutoRetry) {
return { signal: combined }
}
@@ -203,10 +181,6 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
return true
}
if (errorType === "quota_exceeded") {
return true
}
if (statusCode && retryOnErrors.includes(statusCode)) {
return true
}

View File

@@ -1,16 +1,10 @@
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, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { normalizeFallbackModels } 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,
@@ -18,45 +12,22 @@ 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 | FallbackModelObject)[] | undefined => {
const tryGetFallbackFromAgent = (agentName: string): string[] | 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]
@@ -64,7 +35,7 @@ function getRawFallbackModelsForSession(
return normalizeFallbackModels(categoryConfig.fallback_models)
}
}
return undefined
}
@@ -82,5 +53,5 @@ function getRawFallbackModelsForSession(
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
return undefined
return []
}

View File

@@ -1,7 +1,6 @@
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 {
@@ -54,29 +53,4 @@ 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)
})
})

View File

@@ -1,6 +1,6 @@
import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME, RETRYABLE_ERROR_PATTERNS } from "./constants"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState } from "./fallback-state"
@@ -32,14 +32,7 @@ export function createSessionStatusHandler(
const retryMessage = typeof status.message === "string" ? status.message : ""
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
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
}
if (!retrySignal) return
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {

View File

@@ -404,24 +404,6 @@ 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", () => {

View File

@@ -11,7 +11,6 @@ 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"
@@ -24,7 +23,6 @@ interface StartWorkHookInput {
}
interface StartWorkHookOutput {
message?: Record<string, unknown>
parts: Array<{ type: string; text?: string }>
}
@@ -81,9 +79,6 @@ 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

View File

@@ -1,184 +1,108 @@
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 { describe, expect, test } = require("bun:test")
import { createThinkingBlockValidatorHook } from "./hook"
const { createThinkingBlockValidatorHook } = require("./hook")
type TestPart = {
type: string
id: string
text?: string
thinking?: string
data?: string
signature?: string
synthetic?: boolean
}
type TestMessage = {
info: { role: "assistant" | "user" }
info: {
role: string
id?: string
modelID?: string
}
parts: TestPart[]
}
async function runTransform(messages: TestMessage[]): Promise<void> {
const hook = createThinkingBlockValidatorHook()
const transform = hook["experimental.chat.messages.transform"]
function createMessage(info: TestMessage["info"], parts: TestPart[]): TestMessage {
return { info, parts }
}
if (!transform) {
throw new Error("missing thinking block validator transform")
}
function createTextPart(id: string, text: string): TestPart {
return { type: "text", id, text }
}
await transform({}, { messages: messages as never })
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 }
}
describe("createThinkingBlockValidatorHook", () => {
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[]
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")
//#when
await runTransform(messages)
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]),
]
//#then
expect(messages[1]?.parts[0]).toBe(signedThinkingPart)
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[2]?.parts[0]).toBe(previousThinkingPart)
expect(messages[2]?.parts).toEqual([previousThinkingPart, targetTextPart])
})
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[]
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")
//#when
await runTransform(messages)
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]),
]
//#then
expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart)
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[2]?.parts).toEqual([targetTextPart])
})
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[]
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")
//#when
await runTransform(messages)
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]),
]
//#then
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[1]?.parts).toEqual([existingThinkingPart, targetTextPart])
})
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[]
test("skips processing for models without extended thinking", async () => {
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
expect(typeof transform).toBe("function")
//#when
await runTransform(messages)
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]),
]
//#then
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
})
await Reflect.apply(transform, undefined, [{}, { messages }])
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)
expect(messages[2]?.parts).toEqual([targetTextPart])
})
})
export {}

View File

@@ -21,6 +21,11 @@ interface MessageWithParts {
parts: Part[]
}
type SignedThinkingPart = Part & {
type: "thinking" | "redacted_thinking"
signature: string
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
@@ -28,39 +33,25 @@ type MessagesTransformHook = {
) => Promise<void>
}
type SignedThinkingPart = Part & {
type: "thinking" | "redacted_thinking"
thinking?: string
signature: string
synthetic?: boolean
}
/**
* 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()
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
const type = part.type as string
if (type !== "thinking" && type !== "redacted_thinking") {
return false
// Check for explicit thinking/high variants (always enabled)
if (lower.includes("thinking") || lower.endsWith("-high")) {
return true
}
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)),
// 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")
)
}
@@ -88,42 +79,36 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
}
/**
* 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 {
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 {
// 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) {
// Only Anthropic thinking blocks — type must be "thinking", not "reasoning"
if (!isSignedThinkingPart(part)) continue
return part
if (isSignedThinkingPart(part)) {
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 = []
@@ -144,12 +129,13 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
return
}
// 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)) {
// 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)) {
return
}
@@ -162,18 +148,12 @@ 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) {
prependThinkingBlock(msg, previousThinkingPart)
if (!previousThinkingPart) {
continue
}
// 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.
prependThinkingBlock(msg, previousThinkingPart)
}
}
},

View File

@@ -38,7 +38,7 @@ session.idle
## CONSTANTS
```typescript
DEFAULT_SKIP_AGENTS = ["prometheus", "compaction", "plan"]
DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
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

View File

@@ -2,7 +2,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system
export const HOOK_NAME = "todo-continuation-enforcer"
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction", "plan"]
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}

View File

@@ -47,38 +47,4 @@ 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)
})
})

View File

@@ -89,7 +89,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
activePluginDispose = dispose
return {
name: "oh-my-openagent",
...pluginInterface,
"experimental.session.compacting": async (

View File

@@ -60,7 +60,6 @@ describe("applyAgentConfig builtin override protection", () => {
name: "Builtin Sisyphus",
prompt: "builtin prompt",
mode: "primary",
order: 1,
}
const builtinOracleConfig: AgentConfig = {

View File

@@ -1,21 +1,11 @@
import { getAgentDisplayName } from "../shared/agent-display-names";
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;
}
const CORE_AGENT_ORDER = [
getAgentDisplayName("sisyphus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("prometheus"),
getAgentDisplayName("atlas"),
] as const;
export function reorderAgentsByPriority(
agents: Record<string, unknown>,
@@ -23,10 +13,10 @@ export function reorderAgentsByPriority(
const ordered: Record<string, unknown> = {};
const seen = new Set<string>();
for (const { displayName, order } of CORE_AGENT_ORDER) {
if (Object.prototype.hasOwnProperty.call(agents, displayName)) {
ordered[displayName] = injectOrderField(agents[displayName], order);
seen.add(displayName);
for (const key of CORE_AGENT_ORDER) {
if (Object.prototype.hasOwnProperty.call(agents, key)) {
ordered[key] = agents[key];
seen.add(key);
}
}

View File

@@ -39,14 +39,10 @@ export async function buildPrometheusAgentConfig(params: {
connectedProviders: connectedProviders ?? undefined,
});
const configuredPrometheusModel =
params.pluginPrometheusOverride?.model ?? categoryConfig?.model;
const modelResolution = resolveModelPipeline({
intent: {
uiSelectedModel: configuredPrometheusModel ? undefined : params.currentModel,
userModel: params.pluginPrometheusOverride?.model,
categoryDefaultModel: categoryConfig?.model,
uiSelectedModel: params.currentModel,
userModel: params.pluginPrometheusOverride?.model ?? categoryConfig?.model,
},
constraints: { availableModels },
policy: {

View File

@@ -32,13 +32,7 @@ export function createPluginInterface(args: {
return {
tool: tools,
"chat.params": async (input: unknown, output: unknown) => {
const handler = createChatParamsHandler({
anthropicEffort: hooks.anthropicEffort,
client: ctx.client,
})
await handler(input, output)
},
"chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }),
"chat.headers": createChatHeadersHandler({ ctx }),
@@ -74,5 +68,9 @@ export function createPluginInterface(args: {
ctx,
hooks,
}),
"tool.definition": async (input, output) => {
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
},
}
}

View File

@@ -1,17 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { 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
@@ -44,174 +35,4 @@ 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: {},
})
})
})

View File

@@ -1,7 +1,3 @@
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 }
@@ -10,10 +6,6 @@ export type ChatParamsInput = {
message: { variant?: string }
}
type ChatParamsHookInput = ChatParamsInput & {
rawMessage?: Record<string, unknown>
}
export type ChatParamsOutput = {
temperature?: number
topP?: number
@@ -25,7 +17,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
if (!isRecord(raw)) return null
const sessionID = raw.sessionID
@@ -51,11 +43,7 @@ function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
if (!agentName) return null
const providerID = model.providerID
const modelID = typeof model.modelID === "string"
? model.modelID
: typeof model.id === "string"
? model.id
: undefined
const modelID = model.modelID
const providerId = provider.id
const variant = message.variant
@@ -68,9 +56,7 @@ function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
agent: { name: agentName },
model: { providerID, modelID },
provider: { id: providerId },
message,
rawMessage: message,
...(typeof variant === "string" ? {} : {}),
message: typeof variant === "string" ? { variant } : {},
}
}
@@ -83,100 +69,13 @@ function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput {
}
export function createChatParamsHandler(args: {
anthropicEffort: { "chat.params"?: (input: ChatParamsHookInput, output: ChatParamsOutput) => Promise<void> } | null
client?: unknown
anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise<void> } | null
}): (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)
}
}

View File

@@ -2,7 +2,6 @@ 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() {
@@ -54,8 +53,6 @@ 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 () => {

View File

@@ -4,7 +4,6 @@ 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 } }
@@ -442,45 +441,6 @@ 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", () => {

View File

@@ -16,7 +16,7 @@ import {
setSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
import { getFallbackModelsForSession } 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,7 +25,6 @@ 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";
@@ -111,10 +110,10 @@ function applyUserConfiguredFallbackChain(
pluginConfig: OhMyOpenCodeConfig,
): void {
const agentKey = getAgentConfigKey(agentName);
const rawFallbackModels = getRawFallbackModels(sessionID, agentKey, pluginConfig);
if (!rawFallbackModels || rawFallbackModels.length === 0) return;
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
if (configuredFallbackModels.length === 0) return;
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
if (fallbackChain && fallbackChain.length > 0) {
setSessionFallbackChain(sessionID, fallbackChain);
@@ -331,7 +330,6 @@ 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);

View File

@@ -184,7 +184,6 @@ export function createSessionHooks(args: {
showStartupToast: isHookEnabled("startup-toast"),
isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true,
autoUpdate: pluginConfig.auto_update ?? true,
modelCapabilities: pluginConfig.model_capabilities,
}))
: null

View File

@@ -41,16 +41,6 @@ export function createToolExecuteBeforeHandler(args: {
}
return async (input, output): Promise<void> => {
if (input.tool.toLowerCase() === "bash" && typeof output.args.command === "string") {
if (output.args.command.includes("\x00")) {
output.args.command = output.args.command.replace(/\x00/g, "")
log("[tool-execute-before] Stripped null bytes from bash command", {
sessionID: input.sessionID,
callID: input.callID,
})
}
}
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)

View File

@@ -7,7 +7,6 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import {
createConnectedProvidersCacheStore,
findProviderModelMetadata,
} from "./connected-providers-cache"
let fakeUserCacheRoot = ""
@@ -69,14 +68,8 @@ describe("updateConnectedProvidersCache", () => {
expect(cache).not.toBeNull()
expect(cache!.connected).toEqual(["openai", "anthropic"])
expect(cache!.models).toEqual({
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" },
],
openai: ["gpt-5.3-codex", "gpt-5.4"],
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6"],
})
})
@@ -181,86 +174,4 @@ 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",
})
})
})

View File

@@ -11,39 +11,20 @@ interface ConnectedProvidersCache {
updatedAt: string
}
export interface ModelMetadata {
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
}
export interface ProviderModelsCache {
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
) {
@@ -138,7 +119,7 @@ export function createConnectedProvidersCacheStore(
return existsSync(cacheFile)
}
function writeProviderModelsCache(data: { models: Record<string, string[] | ModelMetadata[]>; connected: string[] }): void {
function writeProviderModelsCache(data: { models: Record<string, string[]>; connected: string[] }): void {
ensureCacheDir()
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
@@ -183,27 +164,14 @@ export function createConnectedProvidersCacheStore(
writeConnectedProvidersCache(connected)
const modelsByProvider: Record<string, ModelMetadata[]> = {}
const modelsByProvider: Record<string, string[]> = {}
const allProviders = result.data?.all ?? []
for (const provider of allProviders) {
if (provider.models) {
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
const modelIds = Object.keys(provider.models)
if (modelIds.length > 0) {
modelsByProvider[provider.id] = modelIds
}
}
}
@@ -232,32 +200,6 @@ 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()
)

Some files were not shown because too many files have changed in this diff Show More