Merge remote-tracking branch 'origin/dev' into fix/git-bash-shell-detection-on-windows
# Conflicts: # src/shared/shell-env.ts
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
# src/ — Plugin Source
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
@@ -10,7 +10,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | Plugin entry, exports `OhMyOpenCodePlugin` |
|
||||
| `index.ts` | Plugin entry, default-exports `pluginModule: PluginModule` with `{ id, server }` |
|
||||
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# fixture root
|
||||
@@ -0,0 +1 @@
|
||||
# fixture package
|
||||
@@ -0,0 +1 @@
|
||||
export const file16 = 16
|
||||
@@ -0,0 +1 @@
|
||||
export const file17 = 17
|
||||
@@ -0,0 +1 @@
|
||||
export const file18 = 18
|
||||
@@ -0,0 +1 @@
|
||||
export const file19 = 19
|
||||
@@ -0,0 +1 @@
|
||||
export const file20 = 20
|
||||
@@ -0,0 +1 @@
|
||||
# fixture src
|
||||
@@ -0,0 +1 @@
|
||||
export const file01 = 1
|
||||
@@ -0,0 +1 @@
|
||||
export const file02 = 2
|
||||
@@ -0,0 +1 @@
|
||||
export const file03 = 3
|
||||
@@ -0,0 +1 @@
|
||||
export const file04 = 4
|
||||
@@ -0,0 +1 @@
|
||||
export const file05 = 5
|
||||
@@ -0,0 +1 @@
|
||||
export const file06 = 6
|
||||
@@ -0,0 +1 @@
|
||||
export const file07 = 7
|
||||
@@ -0,0 +1 @@
|
||||
export const file08 = 8
|
||||
@@ -0,0 +1 @@
|
||||
export const file09 = 9
|
||||
@@ -0,0 +1 @@
|
||||
export const file10 = 10
|
||||
@@ -0,0 +1 @@
|
||||
export const file11 = 11
|
||||
@@ -0,0 +1 @@
|
||||
export const file12 = 12
|
||||
@@ -0,0 +1 @@
|
||||
export const file13 = 13
|
||||
@@ -0,0 +1 @@
|
||||
export const file14 = 14
|
||||
@@ -0,0 +1 @@
|
||||
export const file15 = 15
|
||||
@@ -0,0 +1,121 @@
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { describe, expect, it } from "bun:test"
|
||||
|
||||
type InitMetrics = {
|
||||
coldMs: number
|
||||
warmMs: [number, number]
|
||||
medianMs: number
|
||||
}
|
||||
|
||||
function getMedian(values: number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0
|
||||
}
|
||||
|
||||
function createPluginInput(directory: string): PluginInput {
|
||||
const client = createOpencodeClient({ directory })
|
||||
|
||||
return {
|
||||
client,
|
||||
project: {
|
||||
id: `perf-${Date.now()}`,
|
||||
worktree: directory,
|
||||
time: { created: Date.now() },
|
||||
},
|
||||
directory,
|
||||
worktree: directory,
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: Bun.$,
|
||||
}
|
||||
}
|
||||
|
||||
async function importFreshPluginModule(): Promise<(typeof import("../../index"))["default"]> {
|
||||
const token = `${Date.now()}-${Math.random()}`
|
||||
return (await import(`../../index?perf=${token}`)).default
|
||||
}
|
||||
|
||||
async function measureInitMetrics(directory: string): Promise<InitMetrics> {
|
||||
const pluginModule = await importFreshPluginModule()
|
||||
const measurements: number[] = []
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const input = createPluginInput(directory)
|
||||
const start = performance.now()
|
||||
await pluginModule.server(input, {})
|
||||
measurements.push(performance.now() - start)
|
||||
}
|
||||
|
||||
return {
|
||||
coldMs: measurements[0] ?? 0,
|
||||
warmMs: [measurements[1] ?? 0, measurements[2] ?? 0],
|
||||
medianMs: getMedian(measurements),
|
||||
}
|
||||
}
|
||||
|
||||
async function measureScenario(
|
||||
label: string,
|
||||
populateDirectory: (directory: string) => void,
|
||||
): Promise<InitMetrics> {
|
||||
const rootDirectory = mkdtempSync(join(tmpdir(), "perf-d09-"))
|
||||
const projectDirectory = join(rootDirectory, label)
|
||||
const configDirectory = join(rootDirectory, "opencode-config")
|
||||
const previousConfigDirectory = process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
mkdirSync(configDirectory, { recursive: true })
|
||||
process.env.OPENCODE_CONFIG_DIR = configDirectory
|
||||
|
||||
try {
|
||||
populateDirectory(projectDirectory)
|
||||
return await measureInitMetrics(projectDirectory)
|
||||
} finally {
|
||||
if (previousConfigDirectory === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = previousConfigDirectory
|
||||
}
|
||||
|
||||
rmSync(rootDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function logMetrics(label: string, metrics: InitMetrics): void {
|
||||
console.info(
|
||||
`${label}: cold=${metrics.coldMs.toFixed(1)}ms warm=[${metrics.warmMs.map((value) => value.toFixed(1)).join(", ")}] median=${metrics.medianMs.toFixed(1)}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
describe("plugin init performance", () => {
|
||||
it("stays within the empty project init budget", async () => {
|
||||
// given
|
||||
const metrics = await measureScenario("empty-project", (directory) => {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
})
|
||||
|
||||
// when
|
||||
logMetrics("empty-project", metrics)
|
||||
|
||||
// then
|
||||
// regression budget
|
||||
expect(metrics.medianMs).toBeLessThan(500)
|
||||
})
|
||||
|
||||
it("stays within the in-tree fixture init budget", async () => {
|
||||
// given
|
||||
const fixtureDirectory = new URL("./fixtures/in-tree/", import.meta.url)
|
||||
const metrics = await measureScenario("in-tree-fixture", (directory) => {
|
||||
cpSync(fixtureDirectory, directory, { recursive: true })
|
||||
})
|
||||
|
||||
// when
|
||||
logMetrics("in-tree-fixture", metrics)
|
||||
|
||||
// then
|
||||
// regression budget
|
||||
expect(metrics.medianMs).toBeLessThan(700)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/agents/ — 11 Agent Definitions
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
@@ -10,16 +10,16 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
|
||||
|
||||
| Agent | Model | Temp | Mode | Fallback Chain | Purpose |
|
||||
|-------|-------|------|------|----------------|---------|
|
||||
| **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 |
|
||||
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.5 -> gpt-5.4 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
|
||||
| **Hephaestus** | gpt-5.4 medium | 0.1 | all | — | Autonomous deep worker |
|
||||
| **Oracle** | gpt-5.4 high | 0.1 | subagent | gemini-3.1-pro high -> claude-opus-4-6 max | Read-only consultation |
|
||||
| **Oracle** | gpt-5.4 high | 0.1 | subagent | gemini-3.1-pro high -> claude-opus-4-7 max | Read-only consultation |
|
||||
| **Librarian** | minimax-m2.7 | 0.1 | subagent | minimax-m2.7-highspeed -> claude-haiku-4-5 -> gpt-5-nano | External docs/code search |
|
||||
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5-nano | Contextual grep |
|
||||
| **Multimodal-Looker** | gpt-5.3-codex medium | 0.1 | subagent | k2p5 -> gemini-3-flash -> glm-4.6v -> gpt-5-nano | PDF/image analysis |
|
||||
| **Metis** | claude-opus-4-6 max | **0.3** | subagent | gpt-5.4 high -> gemini-3.1-pro high | Pre-planning consultant |
|
||||
| **Momus** | gpt-5.4 xhigh | 0.1 | subagent | claude-opus-4-6 max -> gemini-3.1-pro high | Plan reviewer |
|
||||
| **Metis** | claude-opus-4-7 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-7 max -> gemini-3.1-pro high | Plan reviewer |
|
||||
| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | gpt-5.4 medium | Todo-list orchestrator |
|
||||
| **Prometheus** | claude-opus-4-6 max | 0.1 | — | internal planner | Strategic planner (internal) |
|
||||
| **Prometheus** | claude-opus-4-7 max | 0.1 | — | internal planner | Strategic planner (internal) |
|
||||
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor |
|
||||
|
||||
## TOOL RESTRICTIONS
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("Sisyphus prompt identity", () => {
|
||||
describe("#given a Sisyphus agent created with default model", () => {
|
||||
describe("#when checking the prompt", () => {
|
||||
it("#then contains the agent identity section with override directive", () => {
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-7")
|
||||
|
||||
expect(config.prompt).toContain("<agent-identity>")
|
||||
expect(config.prompt).toContain("Sisyphus")
|
||||
@@ -65,7 +65,7 @@ describe("Sisyphus prompt identity", () => {
|
||||
})
|
||||
|
||||
it("#then identity section appears before the Role section", () => {
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const config = createSisyphusAgent("anthropic/claude-opus-4-7")
|
||||
const prompt = config.prompt ?? ""
|
||||
const identityIndex = prompt.indexOf("<agent-identity>")
|
||||
const roleIndex = prompt.indexOf("<Role>")
|
||||
@@ -115,7 +115,7 @@ describe("Agent identity preservation through overrides", () => {
|
||||
describe("#given a Sisyphus agent with prompt_append override", () => {
|
||||
describe("#when merging the override", () => {
|
||||
it("#then identity section is preserved in the merged prompt", () => {
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-7")
|
||||
const merged = mergeAgentConfig(baseConfig, { prompt_append: "Extra instructions here" })
|
||||
|
||||
expect(merged.prompt).toContain("<agent-identity>")
|
||||
@@ -129,7 +129,7 @@ describe("Agent identity preservation through overrides", () => {
|
||||
describe("#given a Sisyphus agent with model override only", () => {
|
||||
describe("#when merging the override", () => {
|
||||
it("#then identity section is preserved unchanged", () => {
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
|
||||
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-7")
|
||||
const merged = mergeAgentConfig(baseConfig, { model: "openai/gpt-5.4" })
|
||||
|
||||
expect(merged.prompt).toContain("<agent-identity>")
|
||||
|
||||
@@ -150,16 +150,16 @@ task(
|
||||
|
||||
### 3.5 Handle Failures (USE RESUME)
|
||||
|
||||
**CRITICAL: When re-delegating, ALWAYS use \`session_id\` parameter.**
|
||||
**CRITICAL: When re-delegating, ALWAYS use \`task_id\` parameter.**
|
||||
|
||||
Every \`task()\` output includes a session_id. STORE IT.
|
||||
Every \`task()\` output includes a task_id. STORE IT.
|
||||
|
||||
If task fails:
|
||||
1. Identify what went wrong
|
||||
2. **Resume the SAME session** - subagent has full context already:
|
||||
\`\`\`typescript
|
||||
task(
|
||||
session_id="ses_xyz789", // Session from failed task
|
||||
task_id="ses_xyz789", // Task ID from failed task
|
||||
load_skills=[...],
|
||||
prompt="FAILED: {error}. Fix by: {specific instruction}"
|
||||
)
|
||||
@@ -167,7 +167,7 @@ If task fails:
|
||||
3. Maximum 3 retry attempts with the SAME session
|
||||
4. If blocked after 3 attempts: Document and continue to independent tasks
|
||||
|
||||
**Why session_id is MANDATORY for failures:**
|
||||
**Why task_id is MANDATORY for failures:**
|
||||
- Subagent already read all files, knows the context
|
||||
- No repeated exploration = 70%+ token savings
|
||||
- Subagent knows what approaches already failed
|
||||
@@ -292,6 +292,6 @@ export const DEFAULT_ATLAS_CRITICAL_RULES = `<critical_overrides>
|
||||
- Pass inherited wisdom to every subagent
|
||||
- Parallelize independent tasks
|
||||
- Verify with your own tools
|
||||
- **Store session_id from every delegation output**
|
||||
- **Use \`session_id="{session_id}"\` for retries, fixes, and follow-ups**
|
||||
- **Store task_id from every delegation output**
|
||||
- **Use \`task_id="{task_id}"\` for retries, fixes, and follow-ups**
|
||||
</critical_overrides>`
|
||||
|
||||
@@ -164,10 +164,10 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
|
||||
|
||||
### 3.5 Handle Failures
|
||||
|
||||
**CRITICAL: Use \`session_id\` for retries.**
|
||||
**CRITICAL: Use \`task_id\` for retries.**
|
||||
|
||||
\`\`\`typescript
|
||||
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
\`\`\`
|
||||
|
||||
- Maximum 3 retries per task
|
||||
|
||||
@@ -169,10 +169,10 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
|
||||
|
||||
### 3.5 Handle Failures
|
||||
|
||||
**CRITICAL: Use \`session_id\` for retries.**
|
||||
**CRITICAL: Use \`task_id\` for retries.**
|
||||
|
||||
\`\`\`typescript
|
||||
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
\`\`\`
|
||||
|
||||
- Maximum 3 retries per task
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("maybeCreateSisyphusConfig", () => {
|
||||
// given
|
||||
const agentOverrides: AgentOverrides = {
|
||||
sisyphus: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
permission: {
|
||||
apply_patch: "allow",
|
||||
},
|
||||
@@ -55,8 +55,8 @@ describe("maybeCreateSisyphusConfig", () => {
|
||||
const config = maybeCreateSisyphusConfig({
|
||||
disabledAgents: [],
|
||||
agentOverrides,
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "anthropic/claude-opus-4-7",
|
||||
isFirstRunNoCache: false,
|
||||
availableAgents: [],
|
||||
availableSkills: [],
|
||||
@@ -67,7 +67,7 @@ describe("maybeCreateSisyphusConfig", () => {
|
||||
|
||||
// then
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.model).toBe("anthropic/claude-opus-4-6");
|
||||
expect(config?.model).toBe("anthropic/claude-opus-4-7");
|
||||
// Claude models should allow the user override
|
||||
expect(config?.permission).toHaveProperty("apply_patch", "allow");
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { createBuiltinAgents } from "./builtin-agents"
|
||||
import * as shared from "../shared"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-7"
|
||||
|
||||
describe("createBuiltinAgents custom agent visibility", () => {
|
||||
test("#given runtime custom agents #when orchestrator prompts are built #then custom agents are not advertised for automatic delegation", async () => {
|
||||
//#given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -182,7 +182,7 @@ Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementatio
|
||||
|
||||
- Single-file fix or trivial change → proceed directly
|
||||
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
|
||||
- Use \`session_id\` to resume the same Plan Agent - ask follow-up questions aggressively
|
||||
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
|
||||
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
|
||||
|
||||
Plan Agent returns a structured work breakdown with parallel execution opportunities. Follow it.`
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("buildParallelDelegationSection", () => {
|
||||
|
||||
it("#given Claude model #when building #then returns empty", () => {
|
||||
//#given
|
||||
const model = "anthropic/claude-opus-4-6"
|
||||
const model = "anthropic/claude-opus-4-7"
|
||||
const categories = [deepCategory]
|
||||
|
||||
//#when
|
||||
@@ -244,7 +244,7 @@ describe("buildNonClaudePlannerSection", () => {
|
||||
|
||||
//#then
|
||||
expect(result).toContain("Plan Agent")
|
||||
expect(result).toContain("session_id")
|
||||
expect(result).toContain("task_id")
|
||||
expect(result).toContain("Multi-step")
|
||||
})
|
||||
|
||||
|
||||
@@ -25,13 +25,10 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createExploreAgent(model: string): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
])
|
||||
const restrictions = createAgentToolRestrictions(
|
||||
["write", "edit", "apply_patch", "task", "call_omo_agent"],
|
||||
["lsp_symbols", "lsp_goto_definition", "lsp_find_references", "lsp_diagnostics", "ast_grep_search"],
|
||||
)
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("getHephaestusPromptSource", () => {
|
||||
|
||||
test("returns 'gpt' for non-GPT models and undefined", () => {
|
||||
// given
|
||||
const model1 = "anthropic/claude-opus-4-6";
|
||||
const model1 = "anthropic/claude-opus-4-7";
|
||||
const model2 = undefined;
|
||||
|
||||
// when
|
||||
@@ -124,7 +124,7 @@ describe("getHephaestusPrompt", () => {
|
||||
|
||||
test("Claude model returns generic GPT prompt (Hephaestus default)", () => {
|
||||
// given
|
||||
const model = "anthropic/claude-opus-4-6";
|
||||
const model = "anthropic/claude-opus-4-7";
|
||||
|
||||
// when
|
||||
const prompt = getHephaestusPrompt(model);
|
||||
@@ -149,7 +149,7 @@ describe("getHephaestusPrompt", () => {
|
||||
|
||||
test("useTaskSystem=false includes Todo Discipline for Claude models", () => {
|
||||
// given
|
||||
const model = "anthropic/claude-opus-4-6";
|
||||
const model = "anthropic/claude-opus-4-7";
|
||||
|
||||
// when
|
||||
const prompt = getHephaestusPrompt(model, false);
|
||||
@@ -239,7 +239,7 @@ describe("createHephaestusAgent", () => {
|
||||
// given
|
||||
const gpt54Model = "openai/gpt-5.4";
|
||||
const gptGenericModel = "openai/gpt-4o";
|
||||
const claudeModel = "anthropic/claude-opus-4-6";
|
||||
const claudeModel = "anthropic/claude-opus-4-7";
|
||||
|
||||
// when
|
||||
const gpt54Config = createHephaestusAgent(gpt54Model);
|
||||
@@ -322,7 +322,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
|
||||
// given
|
||||
const agentOverrides: AgentOverrides = {
|
||||
hephaestus: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
permission: {
|
||||
apply_patch: "allow",
|
||||
},
|
||||
@@ -334,8 +334,8 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
|
||||
const config = maybeCreateHephaestusConfig({
|
||||
disabledAgents: [],
|
||||
agentOverrides,
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "anthropic/claude-opus-4-7",
|
||||
isFirstRunNoCache: false,
|
||||
availableAgents: [],
|
||||
availableSkills: [],
|
||||
@@ -346,7 +346,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
|
||||
|
||||
// then
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.model).toBe("anthropic/claude-opus-4-6");
|
||||
expect(config?.model).toBe("anthropic/claude-opus-4-7");
|
||||
expect(config?.permission).toHaveProperty("apply_patch", "allow");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,9 +409,9 @@ After delegation, ALWAYS verify: works as expected? follows codebase pattern? MU
|
||||
|
||||
Every \`task()\` output includes a session_id. **USE IT for follow-ups.**
|
||||
|
||||
- **Task failed/incomplete** - \`session_id="{id}", prompt="Fix: {error}"\`
|
||||
- **Follow-up on result** - \`session_id="{id}", prompt="Also: {question}"\`
|
||||
- **Verification failed** - \`session_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
- **Task failed/incomplete** - \`task_id="{id}", prompt="Fix: {error}"\`
|
||||
- **Follow-up on result** - \`task_id="{id}", prompt="Also: {question}"\`
|
||||
- **Verification failed** - \`task_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
|
||||
${
|
||||
oracleSection
|
||||
|
||||
@@ -312,10 +312,10 @@ Every delegation prompt needs these 6 sections:
|
||||
After delegation, verify by reading every file the subagent touched. Check: works as expected? follows codebase pattern? Do not trust self-reports.
|
||||
|
||||
<session_continuity>
|
||||
Every \`task()\` returns a session_id. Use it for all follow-ups:
|
||||
- Task failed/incomplete: \`session_id="{id}", prompt="Fix: {error}"\`
|
||||
- Follow-up on result: \`session_id="{id}", prompt="Also: {question}"\`
|
||||
- Verification failed: \`session_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
Every \`task()\` returns a task_id. Use it for all follow-ups:
|
||||
- Task failed/incomplete: \`task_id="{id}", prompt="Fix: {error}"\`
|
||||
- Follow-up on result: \`task_id="{id}", prompt="Also: {question}"\`
|
||||
- Verification failed: \`task_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
|
||||
This preserves full context, avoids repeated exploration, saves 70%+ tokens.
|
||||
</session_continuity>
|
||||
|
||||
@@ -277,11 +277,11 @@ After delegation, ALWAYS verify: works as expected? follows codebase pattern? MU
|
||||
|
||||
### Session Continuity
|
||||
|
||||
Every \`task()\` output includes a session_id. **USE IT for follow-ups.**
|
||||
Every \`task()\` output includes a task_id. **USE IT for follow-ups.**
|
||||
|
||||
- **Task failed/incomplete** - \`session_id="{id}", prompt="Fix: {error}"\`
|
||||
- **Follow-up on result** - \`session_id="{id}", prompt="Also: {question}"\`
|
||||
- **Verification failed** - \`session_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
- **Task failed/incomplete** - \`task_id="{id}", prompt="Fix: {error}"\`
|
||||
- **Follow-up on result** - \`task_id="{id}", prompt="Also: {question}"\`
|
||||
- **Verification failed** - \`task_id="{id}", prompt="Failed: {error}. Fix."\`
|
||||
|
||||
${
|
||||
oracleSection
|
||||
|
||||
@@ -317,15 +317,15 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
|
||||
### Session Continuity (MANDATORY)
|
||||
|
||||
Every \`task()\` output includes a session_id. **USE IT.**
|
||||
Every \`task()\` output includes a task_id. **USE IT.**
|
||||
|
||||
**ALWAYS continue when:**
|
||||
- Task failed/incomplete → \`session_id=\"{session_id}\", prompt=\"Fix: {specific error}\"\`
|
||||
- Follow-up question on result → \`session_id=\"{session_id}\", prompt=\"Also: {question}\"\`
|
||||
- Multi-turn with same agent → \`session_id=\"{session_id}\"\` - NEVER start fresh
|
||||
- Verification failed → \`session_id=\"{session_id}\", prompt=\"Failed verification: {error}. Fix.\"\`
|
||||
- Task failed/incomplete → \`task_id=\"{task_id}\", prompt=\"Fix: {specific error}\"\`
|
||||
- Follow-up question on result → \`task_id=\"{task_id}\", prompt=\"Also: {question}\"\`
|
||||
- Multi-turn with same agent → \`task_id=\"{task_id}\"\` - NEVER start fresh
|
||||
- Verification failed → \`task_id=\"{task_id}\", prompt=\"Failed verification: {error}. Fix.\"\`
|
||||
|
||||
**Why session_id is CRITICAL:**
|
||||
**Why task_id is CRITICAL:**
|
||||
- Subagent has FULL conversation context preserved
|
||||
- No repeated file reads, exploration, or setup
|
||||
- Saves 70%+ tokens on follow-ups
|
||||
@@ -336,10 +336,10 @@ Every \`task()\` output includes a session_id. **USE IT.**
|
||||
task(category="quick", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix the type error in auth.ts...")
|
||||
|
||||
// CORRECT: Resume preserves everything
|
||||
task(session_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
|
||||
task(task_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
|
||||
\`\`\`
|
||||
|
||||
**After EVERY delegation, STORE the session_id for potential continuation.**
|
||||
**After EVERY delegation, STORE the task_id for potential continuation.**
|
||||
|
||||
### Code Changes:
|
||||
- Match existing patterns (if codebase is disciplined)
|
||||
|
||||
@@ -389,15 +389,15 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
|
||||
### Session Continuity (MANDATORY)
|
||||
|
||||
Every \`task()\` output includes a session_id. **USE IT.**
|
||||
Every \`task()\` output includes a task_id. **USE IT.**
|
||||
|
||||
**ALWAYS continue when:**
|
||||
- Task failed/incomplete → \`session_id="{session_id}", prompt="Fix: {specific error}"\`
|
||||
- Follow-up question on result → \`session_id="{session_id}", prompt="Also: {question}"\`
|
||||
- Multi-turn with same agent → \`session_id="{session_id}"\` - NEVER start fresh
|
||||
- Verification failed → \`session_id="{session_id}", prompt="Failed verification: {error}. Fix."\`
|
||||
- Task failed/incomplete → \`task_id="{task_id}", prompt="Fix: {specific error}"\`
|
||||
- Follow-up question on result → \`task_id="{task_id}", prompt="Also: {question}"\`
|
||||
- Multi-turn with same agent → \`task_id="{task_id}"\` - NEVER start fresh
|
||||
- Verification failed → \`task_id="{task_id}", prompt="Failed verification: {error}. Fix."\`
|
||||
|
||||
**Why session_id is CRITICAL:**
|
||||
**Why task_id is CRITICAL:**
|
||||
- Subagent has FULL conversation context preserved
|
||||
- No repeated file reads, exploration, or setup
|
||||
- Saves 70%+ tokens on follow-ups
|
||||
@@ -408,10 +408,10 @@ Every \`task()\` output includes a session_id. **USE IT.**
|
||||
task(category="quick", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix the type error in auth.ts...")
|
||||
|
||||
// CORRECT: Resume preserves everything
|
||||
task(session_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
|
||||
task(task_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
|
||||
\`\`\`
|
||||
|
||||
**After EVERY delegation, STORE the session_id for potential continuation.**
|
||||
**After EVERY delegation, STORE the task_id for potential continuation.**
|
||||
|
||||
### Code Changes:
|
||||
- Match existing patterns (if codebase is disciplined)
|
||||
|
||||
@@ -387,10 +387,10 @@ Post-delegation: delegation never substitutes for verification. Always run \`<ve
|
||||
|
||||
### Session continuity
|
||||
|
||||
Every \`task()\` returns a session_id. Use it for all follow-ups:
|
||||
- Failed/incomplete → \`session_id="{id}", prompt="Fix: {specific error}"\`
|
||||
- Follow-up → \`session_id="{id}", prompt="Also: {question}"\`
|
||||
- Multi-turn → always \`session_id\`, never start fresh
|
||||
Every \`task()\` returns a task_id. Use it for all follow-ups:
|
||||
- Failed/incomplete → \`task_id="{id}", prompt="Fix: {specific error}"\`
|
||||
- Follow-up → \`task_id="{id}", prompt="Also: {question}"\`
|
||||
- Multi-turn → always \`task_id\`, never start fresh
|
||||
|
||||
This preserves full context, avoids repeated exploration, saves 70%+ tokens.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("isGpt5_4Model", () => {
|
||||
});
|
||||
|
||||
test("does not match non-GPT models", () => {
|
||||
expect(isGpt5_4Model("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGpt5_4Model("anthropic/claude-opus-4-7")).toBe(false);
|
||||
expect(isGpt5_4Model("google/gemini-3.1-pro")).toBe(false);
|
||||
expect(isGpt5_4Model("openai/o1")).toBe(false);
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe("isGptModel", () => {
|
||||
});
|
||||
|
||||
test("claude models are not gpt", () => {
|
||||
expect(isGptModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGptModel("anthropic/claude-opus-4-7")).toBe(false);
|
||||
expect(isGptModel("anthropic/claude-sonnet-4-6")).toBe(false);
|
||||
expect(isGptModel("litellm/anthropic.claude-opus-4-5")).toBe(false);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ describe("isGptModel", () => {
|
||||
});
|
||||
|
||||
test("opencode provider is not gpt", () => {
|
||||
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
expect(isGptModel("opencode/claude-opus-4-7")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("isMiniMaxModel", () => {
|
||||
|
||||
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("anthropic/claude-opus-4-7")).toBe(false);
|
||||
expect(isMiniMaxModel("google/gemini-3.1-pro")).toBe(false);
|
||||
expect(isMiniMaxModel("opencode-go/kimi-k2.5")).toBe(false);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("isGlmModel", () => {
|
||||
|
||||
test("#given non-GLM models #then returns false", () => {
|
||||
expect(isGlmModel("openai/gpt-5.4")).toBe(false);
|
||||
expect(isGlmModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGlmModel("anthropic/claude-opus-4-7")).toBe(false);
|
||||
expect(isGlmModel("google/gemini-3.1-pro")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -156,11 +156,11 @@ describe("isGeminiModel", () => {
|
||||
});
|
||||
|
||||
test("#given claude models #then returns false", () => {
|
||||
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGeminiModel("anthropic/claude-opus-4-7")).toBe(false);
|
||||
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
|
||||
});
|
||||
|
||||
test("#given opencode provider #then returns false", () => {
|
||||
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
expect(isGeminiModel("opencode/claude-opus-4-7")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-23
@@ -7,7 +7,7 @@ import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
import * as modelAvailability from "../shared/model-availability"
|
||||
import * as shared from "../shared"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-7"
|
||||
let createBuiltinAgents: (typeof import("./builtin-agents"))["createBuiltinAgents"]
|
||||
|
||||
async function importFreshBuiltinAgentsModule(): Promise<typeof import("./builtin-agents")> {
|
||||
@@ -32,7 +32,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"kimi-for-coding/k2p5",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-5",
|
||||
@@ -45,7 +45,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
} finally {
|
||||
@@ -170,7 +170,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
|
||||
test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
|
||||
// #given
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-6"
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-7"
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.7")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -299,7 +299,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"kimi-for-coding/k2p5",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-5",
|
||||
@@ -341,7 +341,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes hidden custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -377,7 +377,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes disabled custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -413,7 +413,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
const disabledAgents = ["ReSeArChEr"]
|
||||
@@ -449,7 +449,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("does not advertise duplicate custom agents case-insensitively", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -481,7 +481,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("does not surface custom agent strings in orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -555,7 +555,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
])
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"kimi-for-coding/k2p5",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-5",
|
||||
@@ -569,7 +569,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -590,7 +590,7 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
|
||||
const providers = options?.connectedProviders ?? []
|
||||
return providers.includes("openai")
|
||||
? new Set(["openai/gpt-5.3-codex"])
|
||||
: new Set(["anthropic/claude-opus-4-6"])
|
||||
: new Set(["anthropic/claude-opus-4-7"])
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -609,7 +609,7 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
|
||||
test("hephaestus is not created when no required provider is connected", async () => {
|
||||
// #given - only anthropic models available, not in hephaestus requiresProvider
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
new Set(["anthropic/claude-opus-4-7"])
|
||||
)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
@@ -699,10 +699,10 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
|
||||
test("hephaestus is created when explicit config provided even if provider unavailable", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
new Set(["anthropic/claude-opus-4-7"])
|
||||
)
|
||||
const overrides = {
|
||||
hephaestus: { model: "anthropic/claude-opus-4-6" },
|
||||
hephaestus: { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -781,7 +781,7 @@ describe("Sisyphus and Librarian environment context toggle", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
|
||||
new Set(["anthropic/claude-opus-4-7", "google/gemini-3-flash"])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -840,7 +840,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -893,7 +893,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
test("sisyphus is created when at least one fallback model is available", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
new Set(["anthropic/claude-opus-4-7"])
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -918,7 +918,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.7")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -929,7 +929,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
const overrides = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1039,7 +1039,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
const { buildAgent } = require("./agent-builder")
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-6"
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-7"
|
||||
|
||||
beforeEach(() => {
|
||||
clearSkillCache()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ describe("runCliInstaller telemetry isolation", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("runCliInstaller", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||
@@ -83,6 +84,7 @@ describe("runCliInstaller", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||
|
||||
@@ -138,7 +138,8 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
||||
!config.hasOpenAI &&
|
||||
!config.hasGemini &&
|
||||
!config.hasCopilot &&
|
||||
!config.hasOpencodeZen
|
||||
!config.hasOpencodeZen &&
|
||||
!config.hasVercelAiGateway
|
||||
) {
|
||||
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ program
|
||||
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
||||
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
|
||||
.option("--opencode-go <value>", "OpenCode Go subscription: no, yes (default: no)")
|
||||
.option("--vercel-ai-gateway <value>", "Vercel AI Gateway: no, yes (default: no)")
|
||||
.option("--skip-auth", "Skip authentication setup hints")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -40,14 +41,15 @@ Examples:
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
|
||||
|
||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
|
||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
|
||||
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
|
||||
OpenAI Native openai/ models (GPT-5.4 for Oracle)
|
||||
Gemini Native google/ models (Gemini 3.1 Pro, Flash)
|
||||
Copilot github-copilot/ models (fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
|
||||
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-7, etc.)
|
||||
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
|
||||
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
|
||||
Vercel vercel/ models (universal proxy, always last fallback)
|
||||
`)
|
||||
.action(async (options) => {
|
||||
const args: InstallArgs = {
|
||||
@@ -60,6 +62,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
|
||||
zaiCodingPlan: options.zaiCodingPlan,
|
||||
kimiForCoding: options.kimiForCoding,
|
||||
opencodeGo: options.opencodeGo,
|
||||
vercelAiGateway: options.vercelAiGateway,
|
||||
skipAuth: options.skipAuth ?? false,
|
||||
}
|
||||
const exitCode = await install(args)
|
||||
|
||||
@@ -12,6 +12,7 @@ function detectProvidersFromOmoConfig(): {
|
||||
hasZaiCodingPlan: boolean
|
||||
hasKimiForCoding: boolean
|
||||
hasOpencodeGo: boolean
|
||||
hasVercelAiGateway: boolean
|
||||
} {
|
||||
const omoConfigPath = getOmoConfigPath()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
@@ -21,6 +22,7 @@ function detectProvidersFromOmoConfig(): {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ function detectProvidersFromOmoConfig(): {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +46,9 @@ function detectProvidersFromOmoConfig(): {
|
||||
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
|
||||
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
|
||||
const hasOpencodeGo = configStr.includes('"opencode-go/')
|
||||
const hasVercelAiGateway = configStr.includes('"vercel/')
|
||||
|
||||
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo }
|
||||
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway }
|
||||
} catch {
|
||||
return {
|
||||
hasOpenAI: true,
|
||||
@@ -52,6 +56,7 @@ function detectProvidersFromOmoConfig(): {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +83,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -106,12 +112,13 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
const providers = openCodeConfig.provider as Record<string, unknown> | undefined
|
||||
result.hasGemini = providers ? "google" in providers : false
|
||||
|
||||
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo } = detectProvidersFromOmoConfig()
|
||||
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway } = detectProvidersFromOmoConfig()
|
||||
result.hasOpenAI = hasOpenAI
|
||||
result.hasOpencodeZen = hasOpencodeZen
|
||||
result.hasZaiCodingPlan = hasZaiCodingPlan
|
||||
result.hasKimiForCoding = hasKimiForCoding
|
||||
result.hasOpencodeGo = hasOpencodeGo
|
||||
result.hasVercelAiGateway = hasVercelAiGateway
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -25,8 +26,8 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
//#then
|
||||
expect([
|
||||
"github-copilot/claude-opus-4.6",
|
||||
"github-copilot/claude-opus-4-6",
|
||||
"github-copilot/claude-opus-4.7",
|
||||
"github-copilot/claude-opus-4-7",
|
||||
]).toContain((result.agents as Record<string, { model: string }>).sisyphus.model)
|
||||
})
|
||||
|
||||
@@ -42,6 +43,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -64,6 +66,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: true,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -71,7 +74,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
//#then
|
||||
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("uses native OpenAI models when only ChatGPT available", () => {
|
||||
@@ -86,6 +89,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -110,6 +114,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -126,7 +131,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
}>
|
||||
|
||||
//#then
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(agents.sisyphus.fallback_models).toEqual([
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
@@ -136,7 +141,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
expect(categories.deep.model).toBe("openai/gpt-5.4")
|
||||
expect(categories.deep.fallback_models).toEqual([
|
||||
{
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
])
|
||||
@@ -154,6 +159,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -175,6 +181,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
|
||||
@@ -20,6 +20,7 @@ const installConfig: InstallConfig = {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# src/cli/doctor/ — Health Diagnostics (25 Check Files)
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
`bunx oh-my-opencode doctor` — parallel diagnostic checks across 4 categories (System, Config, Tools, Models). Catches broken installs, config typos, missing dependencies, provider misconfigurations before they become runtime errors.
|
||||
|
||||
## COMMAND FLAGS
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode doctor # Full diagnostics (all 4 categories)
|
||||
bunx oh-my-opencode doctor --status # Compact dashboard (status only)
|
||||
bunx oh-my-opencode doctor --verbose # Deep details (model resolution traces)
|
||||
bunx oh-my-opencode doctor --json # Machine-readable output
|
||||
```
|
||||
|
||||
## CHECK CATEGORIES
|
||||
|
||||
| Category | File | Validates |
|
||||
|----------|------|-----------|
|
||||
| **SYSTEM** | `checks/system.ts` | OpenCode binary found + version ≥1.0.150, plugin registered in opencode.json, loaded plugin version matches installed |
|
||||
| **CONFIG** | `checks/config.ts` | JSONC validity, Zod schema passes, no unknown keys, model override syntax correct |
|
||||
| **TOOLS** | `checks/tools.ts` | AST-Grep CLI + NAPI, comment-checker binary, LSP servers reachable, GitHub CLI auth, built-in MCP reachability |
|
||||
| **MODELS** | `checks/model-resolution.ts` | models.json cache exists, per-agent fallback resolution, category overrides valid, provider availability |
|
||||
|
||||
## SUPPORTING CHECK FILES (25 total)
|
||||
|
||||
```
|
||||
checks/
|
||||
├── index.ts # Registration
|
||||
├── system.ts # Main System aggregator
|
||||
├── system-binary.ts # OpenCode binary discovery (PATH + desktop app)
|
||||
├── system-plugin.ts # opencode.json plugin entry detection
|
||||
├── system-loaded-version.ts # Cache vs npm latest
|
||||
├── config.ts # Main Config aggregator
|
||||
├── tools.ts # Main Tools aggregator
|
||||
├── dependencies.ts # AST-Grep CLI/NAPI + comment-checker presence
|
||||
├── tools-gh.ts # gh cli install + auth status
|
||||
├── tools-lsp.ts # LSP server enumeration
|
||||
├── tools-mcp.ts # Built-in + user MCP reachability
|
||||
├── model-resolution.ts # Main Models aggregator
|
||||
├── model-resolution-cache.ts # models.json presence + freshness
|
||||
├── model-resolution-config.ts # oh-my-opencode.jsonc parse
|
||||
├── model-resolution-effective-model.ts # Per-agent fallback chain trace
|
||||
├── model-resolution-variant.ts # Model variant (max, high, medium) handling
|
||||
├── model-resolution-details.ts # Verbose output formatter
|
||||
└── model-resolution-types.ts # Shared types
|
||||
```
|
||||
|
||||
## EXECUTION FLOW
|
||||
|
||||
```
|
||||
doctor command
|
||||
→ runner.ts: parallel check execution with 30s per-check timeout
|
||||
→ checks/index.ts registers all 4 category checks
|
||||
→ each check returns: { status: "ok" | "warn" | "error", detail: string }
|
||||
→ formatter.ts: render to stdout (text/status/json)
|
||||
→ exit code: 0 (all ok) | 1 (errors) | 2 (warnings only)
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | CLI command entry, flag parsing |
|
||||
| `runner.ts` | Parallel `Promise.allSettled()` orchestration, 30s timeout per check |
|
||||
| `formatter.ts` | Pretty printing: colored status, hierarchical output |
|
||||
| `types.ts` | `DoctorCheck`, `CheckResult`, `DoctorReport` types |
|
||||
|
||||
## HOW TO ADD A CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/{name}.ts` exporting check function matching `DoctorCheck`
|
||||
2. Register in `checks/index.ts`
|
||||
3. Category-level aggregator (system/config/tools/model-resolution) invokes it
|
||||
4. Return `{ status, detail }` — no throws, all errors caught by runner
|
||||
|
||||
## EXIT CODES
|
||||
|
||||
- `0`: All checks passed (or only info messages)
|
||||
- `1`: One or more errors — plugin will likely not work
|
||||
- `2`: Warnings only — plugin works with degraded features
|
||||
@@ -34,7 +34,7 @@ describe("loadAvailableModelsFromCache", () => {
|
||||
join(tempDir, "cache", "opencode", "models.json"),
|
||||
JSON.stringify({
|
||||
openai: { models: { "gpt-5.4": {} } },
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } },
|
||||
anthropic: { models: { "claude-opus-4-7": {}, "claude-sonnet-4-6": {} } },
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("model-resolution check", () => {
|
||||
// then: Should have agent entries
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-7")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("model-resolution check", () => {
|
||||
// given: User has override for oracle agent
|
||||
const mockConfig = {
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-6" },
|
||||
oracle: { model: "anthropic/claude-opus-4-7" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("model-resolution check", () => {
|
||||
// then: Oracle should show the override
|
||||
const oracle = info.agents.find((a) => a.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-6")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-6")
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-7")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
it("shows user override for category when configured", async () => {
|
||||
@@ -169,13 +169,13 @@ describe("model-resolution check", () => {
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-6-thinking" },
|
||||
oracle: { model: "anthropic/claude-opus-4-7-thinking" },
|
||||
},
|
||||
})
|
||||
|
||||
const oracle = info.agents.find((agent) => agent.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-6-thinking")
|
||||
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-7-thinking")
|
||||
expect(oracle!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "alias-backed",
|
||||
canonicalization: {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
|
||||
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
|
||||
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
|
||||
lines.push(formatProvider("Vercel AI Gateway", config.hasVercelAiGateway, "universal proxy"))
|
||||
|
||||
lines.push("")
|
||||
lines.push(color.dim("─".repeat(40)))
|
||||
@@ -153,6 +154,10 @@ export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors:
|
||||
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
if (args.vercelAiGateway !== undefined && !["no", "yes"].includes(args.vercelAiGateway)) {
|
||||
errors.push(`Invalid --vercel-ai-gateway value: ${args.vercelAiGateway} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
@@ -167,6 +172,7 @@ export function argsToConfig(args: InstallArgs): InstallConfig {
|
||||
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
|
||||
hasKimiForCoding: args.kimiForCoding === "yes",
|
||||
hasOpencodeGo: args.opencodeGo === "yes",
|
||||
hasVercelAiGateway: args.vercelAiGateway === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +185,7 @@ export function detectedToInitialValues(detected: DetectedConfig): {
|
||||
zaiCodingPlan: BooleanArg
|
||||
kimiForCoding: BooleanArg
|
||||
opencodeGo: BooleanArg
|
||||
vercelAiGateway: BooleanArg
|
||||
} {
|
||||
let claude: ClaudeSubscription = "no"
|
||||
if (detected.hasClaude) {
|
||||
@@ -194,5 +201,6 @@ kimiForCoding: BooleanArg
|
||||
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
|
||||
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
|
||||
opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
|
||||
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ProviderAvailability {
|
||||
zai: boolean
|
||||
kimiForCoding: boolean
|
||||
opencodeGo: boolean
|
||||
vercelAiGateway: boolean
|
||||
isMaxPlan: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -380,7 +381,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("Sisyphus is created when multiple fallback providers are available", () => {
|
||||
@@ -397,7 +398,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("Sisyphus resolves to gpt-5.4 medium when only OpenAI is available", () => {
|
||||
@@ -572,13 +573,9 @@ describe("generateModelConfig", () => {
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should not have fallback_models (only one chain entry matches)
|
||||
// #then explore should not have fallback_models (only one distinct chain entry matches)
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(result.agents?.explore?.fallback_models).toEqual([
|
||||
{
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
},
|
||||
])
|
||||
expect(result.agents?.explore?.fallback_models).toBeUndefined()
|
||||
})
|
||||
|
||||
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
|
||||
@@ -607,6 +604,74 @@ describe("generateModelConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Vercel AI Gateway provider", () => {
|
||||
test("uses vercel/ model strings when only Vercel AI Gateway is available", () => {
|
||||
// #given only Vercel AI Gateway is available
|
||||
const config = createConfig({ hasVercelAiGateway: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then should use vercel/<sub-provider>/<model> format
|
||||
expect(result).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("uses vercel/ model strings with isMax20 flag", () => {
|
||||
// #given Vercel AI Gateway is available with Max 20 plan
|
||||
const config = createConfig({ hasVercelAiGateway: true, isMax20: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then should use higher capability models via gateway
|
||||
expect(result).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("explore uses vercel/minimax/minimax-m2.7-highspeed when only gateway available", () => {
|
||||
// #given only Vercel AI Gateway is available
|
||||
const config = createConfig({ hasVercelAiGateway: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should use gateway-routed minimax (preferred over claude-haiku)
|
||||
expect(result.agents?.explore?.model).toBe("vercel/minimax/minimax-m2.7-highspeed")
|
||||
})
|
||||
|
||||
test("librarian uses vercel/minimax/minimax-m2.7 when only gateway available", () => {
|
||||
// #given only Vercel AI Gateway is available
|
||||
const config = createConfig({ hasVercelAiGateway: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then librarian should use gateway-routed minimax (preferred over claude-haiku)
|
||||
expect(result.agents?.librarian?.model).toBe("vercel/minimax/minimax-m2.7")
|
||||
})
|
||||
|
||||
test("Hephaestus is created when only Vercel AI Gateway is available", () => {
|
||||
// #given only Vercel AI Gateway is available
|
||||
const config = createConfig({ hasVercelAiGateway: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then hephaestus should be created with gateway-routed gpt-5.4
|
||||
expect(result.agents?.hephaestus?.model).toBe("vercel/openai/gpt-5.4")
|
||||
})
|
||||
|
||||
test("native providers take priority over gateway", () => {
|
||||
// #given Claude and Vercel AI Gateway are both available
|
||||
const config = createConfig({ hasClaude: true, hasVercelAiGateway: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then should prefer native anthropic over gateway
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("schema URL", () => {
|
||||
test("always includes correct schema URL", () => {
|
||||
// #given any config
|
||||
|
||||
@@ -105,7 +105,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
avail.copilot ||
|
||||
avail.zai ||
|
||||
avail.kimiForCoding ||
|
||||
avail.opencodeGo
|
||||
avail.opencodeGo ||
|
||||
avail.vercelAiGateway
|
||||
if (!hasAnyProvider) {
|
||||
return {
|
||||
$schema: SCHEMA_URL,
|
||||
@@ -130,6 +131,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.zai) {
|
||||
agentConfig = { model: ZAI_MODEL }
|
||||
} else if (avail.vercelAiGateway) {
|
||||
agentConfig = { model: "vercel/minimax/minimax-m2.7" }
|
||||
}
|
||||
if (agentConfig) {
|
||||
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
|
||||
@@ -147,6 +150,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.copilot) {
|
||||
agentConfig = { model: "github-copilot/gpt-5-mini" }
|
||||
} else if (avail.vercelAiGateway) {
|
||||
agentConfig = { model: "vercel/minimax/minimax-m2.7-highspeed" }
|
||||
} else {
|
||||
agentConfig = { model: "opencode/gpt-5-nano" }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function toProviderAvailability(config: InstallConfig): ProviderAvailabil
|
||||
zai: config.hasZaiCodingPlan,
|
||||
kimiForCoding: config.hasKimiForCoding,
|
||||
opencodeGo: config.hasOpencodeGo,
|
||||
vercelAiGateway: config.hasVercelAiGateway,
|
||||
isMaxPlan: config.isMax20,
|
||||
}
|
||||
}
|
||||
@@ -27,6 +28,7 @@ export function isProviderAvailable(provider: string, availability: ProviderAvai
|
||||
"zai-coding-plan": availability.zai,
|
||||
"kimi-for-coding": availability.kimiForCoding,
|
||||
"opencode-go": availability.opencodeGo,
|
||||
vercel: availability.vercelAiGateway,
|
||||
}
|
||||
return mapping[provider] ?? false
|
||||
}
|
||||
|
||||
@@ -1,229 +1,353 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { transformModelForProvider } from "./provider-model-id-transform"
|
||||
import { transformModelForProvider as transformSharedModelForProvider } from "../shared/provider-model-id-transform"
|
||||
|
||||
describe("transformModelForProvider", () => {
|
||||
describe("github-copilot provider", () => {
|
||||
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
|
||||
// #given github-copilot provider and claude-opus-4-6 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-opus-4-6"
|
||||
describe("github-copilot provider", () => {
|
||||
test("transforms claude-opus-4-7 to claude-opus-4.7", () => {
|
||||
// #given github-copilot provider and claude-opus-4-7 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-opus-4.6
|
||||
expect(result).toBe("claude-opus-4.6")
|
||||
})
|
||||
// #then should transform to claude-opus-4.7
|
||||
expect(result).toBe("claude-opus-4.7")
|
||||
})
|
||||
|
||||
test("transforms claude-sonnet-4-5 to claude-sonnet-4.5", () => {
|
||||
// #given github-copilot provider and claude-sonnet-4-5 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-sonnet-4-5"
|
||||
test("transforms claude-sonnet-4-5 to claude-sonnet-4.5", () => {
|
||||
// #given github-copilot provider and claude-sonnet-4-5 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-sonnet-4-5"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-sonnet-4.5
|
||||
expect(result).toBe("claude-sonnet-4.5")
|
||||
})
|
||||
// #then should transform to claude-sonnet-4.5
|
||||
expect(result).toBe("claude-sonnet-4.5")
|
||||
})
|
||||
|
||||
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
|
||||
// #given github-copilot provider and claude-haiku-4-5 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-haiku-4-5"
|
||||
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
|
||||
// #given github-copilot provider and claude-haiku-4-5 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-haiku-4-5"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-haiku-4.5
|
||||
expect(result).toBe("claude-haiku-4.5")
|
||||
})
|
||||
// #then should transform to claude-haiku-4.5
|
||||
expect(result).toBe("claude-haiku-4.5")
|
||||
})
|
||||
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro model
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3.1-pro"
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro model
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3.1-pro"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-flash model
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-flash"
|
||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-flash model
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-flash"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3-flash-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
// #then should transform to gemini-3-flash-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-flash-preview model (already transformed)
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-flash-preview"
|
||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-flash-preview model (already transformed)
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-flash-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3-flash-preview-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
})
|
||||
// #then should NOT become gemini-3-flash-preview-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
})
|
||||
|
||||
describe("google provider", () => {
|
||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||
// #given google provider and gemini-3-flash model
|
||||
const provider = "google"
|
||||
const model = "gemini-3-flash"
|
||||
describe("google provider", () => {
|
||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||
// #given google provider and gemini-3-flash model
|
||||
const provider = "google"
|
||||
const model = "gemini-3-flash"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3-flash-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
// #then should transform to gemini-3-flash-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro model
|
||||
const provider = "google"
|
||||
const model = "gemini-3.1-pro"
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro model
|
||||
const provider = "google"
|
||||
const model = "gemini-3.1-pro"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("passes through other gemini models unchanged", () => {
|
||||
// #given google provider and gemini-2.5-flash model
|
||||
const provider = "google"
|
||||
const model = "gemini-2.5-flash"
|
||||
test("passes through other gemini models unchanged", () => {
|
||||
// #given google provider and gemini-2.5-flash model
|
||||
const provider = "google"
|
||||
const model = "gemini-2.5-flash"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should pass through unchanged
|
||||
expect(result).toBe("gemini-2.5-flash")
|
||||
})
|
||||
// #then should pass through unchanged
|
||||
expect(result).toBe("gemini-2.5-flash")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||
// #given google provider and gemini-3-flash-preview model (already transformed)
|
||||
const provider = "google"
|
||||
const model = "gemini-3-flash-preview"
|
||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||
// #given google provider and gemini-3-flash-preview model (already transformed)
|
||||
const provider = "google"
|
||||
const model = "gemini-3-flash-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3-flash-preview-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
// #then should NOT become gemini-3-flash-preview-preview
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "google"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "google"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("does not transform claude models for google provider", () => {
|
||||
// #given google provider and claude-opus-4-6 model
|
||||
const provider = "google"
|
||||
const model = "claude-opus-4-6"
|
||||
test("does not transform claude models for google provider", () => {
|
||||
// #given google provider and claude-opus-4-7 model
|
||||
const provider = "google"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should pass through unchanged (google doesn't use claude)
|
||||
expect(result).toBe("claude-opus-4-6")
|
||||
})
|
||||
})
|
||||
// #then should pass through unchanged (google doesn't use claude)
|
||||
expect(result).toBe("claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("anthropic provider", () => {
|
||||
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
|
||||
// #given anthropic provider and claude-opus-4-6 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-opus-4-6"
|
||||
describe("anthropic provider", () => {
|
||||
test("preserves hyphenated claude-opus-4-7 for config output (regression: installer must not write dotted IDs)", () => {
|
||||
// #given anthropic provider and claude-opus-4-7 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-opus-4.6
|
||||
expect(result).toBe("claude-opus-4.6")
|
||||
})
|
||||
// #then should keep hyphenated form so Anthropic provider resolution succeeds on fresh installs
|
||||
expect(result).toBe("claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("transforms claude-sonnet-4-6 to claude-sonnet-4.6", () => {
|
||||
// #given anthropic provider and claude-sonnet-4-6 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-sonnet-4-6"
|
||||
test("preserves hyphenated claude-sonnet-4-6 for config output", () => {
|
||||
// #given anthropic provider and claude-sonnet-4-6 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-sonnet-4-6"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-sonnet-4.6
|
||||
expect(result).toBe("claude-sonnet-4.6")
|
||||
})
|
||||
// #then should keep hyphenated form
|
||||
expect(result).toBe("claude-sonnet-4-6")
|
||||
})
|
||||
|
||||
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
|
||||
// #given anthropic provider and claude-haiku-4-5 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-haiku-4-5"
|
||||
test("preserves hyphenated claude-haiku-4-5 for config output", () => {
|
||||
// #given anthropic provider and claude-haiku-4-5 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-haiku-4-5"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-haiku-4.5
|
||||
expect(result).toBe("claude-haiku-4.5")
|
||||
})
|
||||
})
|
||||
// #then should keep hyphenated form
|
||||
expect(result).toBe("claude-haiku-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unknown provider", () => {
|
||||
test("passes model through unchanged for unknown provider", () => {
|
||||
// #given unknown provider and any model
|
||||
const provider = "unknown-provider"
|
||||
const model = "some-model"
|
||||
describe("vercel provider", () => {
|
||||
test("prepends anthropic/ and applies anthropic transform for claude models", () => {
|
||||
// #given vercel provider and claude-opus-4-7 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "claude-opus-4-7")
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
// #then should produce anthropic/claude-opus-4.7
|
||||
expect(result).toBe("anthropic/claude-opus-4.7")
|
||||
})
|
||||
|
||||
// #then should pass through unchanged
|
||||
expect(result).toBe("some-model")
|
||||
})
|
||||
test("prepends anthropic/ and applies anthropic transform for claude-sonnet", () => {
|
||||
// #given vercel provider and claude-sonnet-4-6 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "claude-sonnet-4-6")
|
||||
|
||||
test("passes gemini-3-flash through unchanged for unknown provider", () => {
|
||||
// #given unknown provider and gemini-3-flash model
|
||||
const provider = "unknown-provider"
|
||||
const model = "gemini-3-flash"
|
||||
// #then should produce anthropic/claude-sonnet-4.6
|
||||
expect(result).toBe("anthropic/claude-sonnet-4.6")
|
||||
})
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
test("prepends anthropic/ and applies anthropic transform for claude-haiku", () => {
|
||||
// #given vercel provider and claude-haiku-4-5 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "claude-haiku-4-5")
|
||||
|
||||
// #then should pass through unchanged (no transformation for unknown provider)
|
||||
expect(result).toBe("gemini-3-flash")
|
||||
})
|
||||
})
|
||||
// #then should produce anthropic/claude-haiku-4.5
|
||||
expect(result).toBe("anthropic/claude-haiku-4.5")
|
||||
})
|
||||
|
||||
test("prepends openai/ for gpt models", () => {
|
||||
// #given vercel provider and gpt-5.4 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "gpt-5.4")
|
||||
|
||||
// #then should produce openai/gpt-5.4
|
||||
expect(result).toBe("openai/gpt-5.4")
|
||||
})
|
||||
|
||||
test("prepends google/ and applies google transform for gemini models", () => {
|
||||
// #given vercel provider and gemini-3.1-pro model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "gemini-3.1-pro")
|
||||
|
||||
// #then should produce google/gemini-3.1-pro-preview
|
||||
expect(result).toBe("google/gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("prepends google/ without -preview for gemini-3-flash", () => {
|
||||
// #given vercel provider and gemini-3-flash model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "gemini-3-flash")
|
||||
|
||||
// #then should produce google/gemini-3-flash (gateway does not use -preview for this model)
|
||||
expect(result).toBe("google/gemini-3-flash")
|
||||
})
|
||||
|
||||
test("prepends xai/ for grok models", () => {
|
||||
// #given vercel provider and grok-code-fast-1 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "grok-code-fast-1")
|
||||
|
||||
// #then should produce xai/grok-code-fast-1
|
||||
expect(result).toBe("xai/grok-code-fast-1")
|
||||
})
|
||||
|
||||
test("delegates to sub-provider when model already has sub-provider prefix", () => {
|
||||
// #given vercel provider and anthropic/claude-opus-4-7 (already prefixed)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "anthropic/claude-opus-4-7")
|
||||
|
||||
// #then should apply anthropic transform within the prefix
|
||||
expect(result).toBe("anthropic/claude-opus-4.7")
|
||||
})
|
||||
|
||||
test("prepends minimax/ for minimax models", () => {
|
||||
// #given vercel provider and minimax-m2.7 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "minimax-m2.7")
|
||||
|
||||
// #then should produce minimax/minimax-m2.7
|
||||
expect(result).toBe("minimax/minimax-m2.7")
|
||||
})
|
||||
|
||||
test("prepends moonshotai/ for kimi models", () => {
|
||||
// #given vercel provider and kimi-k2.5 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "kimi-k2.5")
|
||||
|
||||
// #then should produce moonshotai/kimi-k2.5
|
||||
expect(result).toBe("moonshotai/kimi-k2.5")
|
||||
})
|
||||
|
||||
test("prepends zai/ for glm models", () => {
|
||||
// #given vercel provider and glm-5 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "glm-5")
|
||||
|
||||
// #then should produce zai/glm-5
|
||||
expect(result).toBe("zai/glm-5")
|
||||
})
|
||||
|
||||
test("passes through unknown models without sub-provider prefix", () => {
|
||||
// #given vercel provider and an unknown model name
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "big-pickle")
|
||||
|
||||
// #then should pass through unchanged
|
||||
expect(result).toBe("big-pickle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unknown provider", () => {
|
||||
test("passes model through unchanged for unknown provider", () => {
|
||||
// #given unknown provider and any model
|
||||
const provider = "unknown-provider"
|
||||
const model = "some-model"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should pass through unchanged
|
||||
expect(result).toBe("some-model")
|
||||
})
|
||||
|
||||
test("passes gemini-3-flash through unchanged for unknown provider", () => {
|
||||
// #given unknown provider and gemini-3-flash model
|
||||
const provider = "unknown-provider"
|
||||
const model = "gemini-3-flash"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should pass through unchanged (no transformation for unknown provider)
|
||||
expect(result).toBe("gemini-3-flash")
|
||||
})
|
||||
})
|
||||
|
||||
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
|
||||
// #given the CLI transform (used by the installer) and the shared runtime transform
|
||||
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-7")
|
||||
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-7")
|
||||
|
||||
// #when both are called with the same anthropic claude input
|
||||
// #then the CLI preserves hyphenated form for config output,
|
||||
// the shared runtime transform converts dash→dot for API calls
|
||||
expect(transformModelForProvider).not.toBe(transformSharedModelForProvider)
|
||||
expect(cliResult).toBe("claude-opus-4-7")
|
||||
expect(sharedResult).toBe("claude-opus-4.7")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +1,66 @@
|
||||
export { transformModelForProvider } from "../shared/provider-model-id-transform"
|
||||
function inferSubProvider(model: string): string | undefined {
|
||||
if (model.startsWith("claude-")) return "anthropic"
|
||||
if (model.startsWith("gpt-")) return "openai"
|
||||
if (model.startsWith("gemini-")) return "google"
|
||||
if (model.startsWith("grok-")) return "xai"
|
||||
if (model.startsWith("minimax-")) return "minimax"
|
||||
if (model.startsWith("kimi-")) return "moonshotai"
|
||||
if (model.startsWith("glm-")) return "zai"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
|
||||
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
|
||||
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
|
||||
|
||||
function claudeVersionDot(model: string): string {
|
||||
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
|
||||
}
|
||||
|
||||
function applyGatewayTransforms(model: string): string {
|
||||
return claudeVersionDot(model).replace(
|
||||
GEMINI_31_PRO_PREVIEW,
|
||||
"gemini-3.1-pro-preview",
|
||||
)
|
||||
}
|
||||
|
||||
export function transformModelForProvider(provider: string, model: string): string {
|
||||
if (provider === "vercel") {
|
||||
const slashIndex = model.indexOf("/")
|
||||
if (slashIndex !== -1) {
|
||||
const subProvider = model.substring(0, slashIndex)
|
||||
const subModel = model.substring(slashIndex + 1)
|
||||
return `${subProvider}/${applyGatewayTransforms(subModel)}`
|
||||
}
|
||||
|
||||
const subProvider = inferSubProvider(model)
|
||||
if (subProvider) {
|
||||
return `${subProvider}/${applyGatewayTransforms(model)}`
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
if (provider === "github-copilot") {
|
||||
return claudeVersionDot(model)
|
||||
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||
}
|
||||
|
||||
if (provider === "google") {
|
||||
return model
|
||||
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
// Installer writes hyphenated IDs (claude-opus-4-7) to the config. The
|
||||
// runtime provider-model-id-transform converts dash→dot when calling the
|
||||
// Anthropic API. Keeping the dotted form in the config breaks fresh
|
||||
// installs with ProviderModelNotFoundError because Anthropic's provider
|
||||
// registers models under hyphenated IDs.
|
||||
return model
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("refreshModelCapabilities", () => {
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6" },
|
||||
"claude-opus-4-7": { id: "claude-opus-4-7" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pc from "picocolors"
|
||||
import type { RunOptions } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { getAgentConfigKey, getAgentDisplayName, getAgentRuntimeName } from "../../shared/agent-display-names"
|
||||
|
||||
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
|
||||
const DEFAULT_AGENT = "sisyphus"
|
||||
@@ -21,11 +21,12 @@ const normalizeAgentName = (agent?: string): ResolvedAgent | undefined => {
|
||||
|
||||
const configKey = getAgentConfigKey(trimmed)
|
||||
const displayName = getAgentDisplayName(configKey)
|
||||
const runtimeName = getAgentRuntimeName(configKey)
|
||||
const isKnownAgent = displayName !== configKey
|
||||
|
||||
return {
|
||||
configKey,
|
||||
resolvedName: isKnownAgent ? displayName : trimmed,
|
||||
resolvedName: isKnownAgent ? runtimeName : trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,27 +62,28 @@ export const resolveRunAgent = (
|
||||
envAgent ??
|
||||
configAgent ?? {
|
||||
configKey: DEFAULT_AGENT,
|
||||
resolvedName: getAgentDisplayName(DEFAULT_AGENT),
|
||||
resolvedName: getAgentRuntimeName(DEFAULT_AGENT),
|
||||
}
|
||||
|
||||
if (isAgentDisabled(resolved.configKey, pluginConfig)) {
|
||||
const fallback = pickFallbackAgent(pluginConfig)
|
||||
const fallbackName = getAgentDisplayName(fallback)
|
||||
const fallbackDisplayName = getAgentDisplayName(fallback)
|
||||
const fallbackRuntimeName = getAgentRuntimeName(fallback)
|
||||
const fallbackDisabled = isAgentDisabled(fallback, pluginConfig)
|
||||
if (fallbackDisabled) {
|
||||
console.log(
|
||||
pc.yellow(
|
||||
`Requested agent "${resolved.resolvedName}" is disabled and no enabled core agent was found. Proceeding with "${fallbackName}".`
|
||||
`Requested agent "${resolved.resolvedName}" is disabled and no enabled core agent was found. Proceeding with "${fallbackDisplayName}".`
|
||||
)
|
||||
)
|
||||
return fallbackName
|
||||
return fallbackRuntimeName
|
||||
}
|
||||
console.log(
|
||||
pc.yellow(
|
||||
`Requested agent "${resolved.resolvedName}" is disabled. Falling back to "${fallbackName}".`
|
||||
`Requested agent "${resolved.resolvedName}" is disabled. Falling back to "${fallbackDisplayName}".`
|
||||
)
|
||||
)
|
||||
return fallbackName
|
||||
return fallbackRuntimeName
|
||||
}
|
||||
|
||||
return resolved.resolvedName
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("message.part.delta handling", () => {
|
||||
sessionID: "ses_main",
|
||||
role: "assistant",
|
||||
agent: "Sisyphus - Ultraworker",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
@@ -113,7 +113,7 @@ describe("message.part.delta handling", () => {
|
||||
//#then
|
||||
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
|
||||
expect(rendered).toContain("\u001b[38;2;0;206;209m")
|
||||
expect(rendered).toContain("claude-opus-4-6 (max)")
|
||||
expect(rendered).toContain("claude-opus-4-7 (max)")
|
||||
expect(rendered).toContain("└─")
|
||||
expect(rendered).toContain("Sisyphus - Ultraworker")
|
||||
stdoutSpy.mockRestore()
|
||||
@@ -128,7 +128,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,7 +187,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +242,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -309,7 +309,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -353,7 +353,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6", variant: "max" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7", variant: "max" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -388,7 +388,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -410,7 +410,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -619,7 +619,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -634,7 +634,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -235,6 +235,27 @@ describe("pollForCompletion", () => {
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("treats missing main session status as idle when status API omits idle sessions", async () => {
|
||||
//#given - latest opencode omits idle sessions from the status map
|
||||
const ctx = createMockContext({
|
||||
statuses: {},
|
||||
})
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = false
|
||||
eventState.hasReceivedMeaningfulWork = true
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 2,
|
||||
minStabilizationMs: 10,
|
||||
})
|
||||
|
||||
//#then - missing entry is treated as idle instead of hanging forever
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("allows silent completion after stabilization when no meaningful work is received", async () => {
|
||||
//#given - session is idle and stable but no assistant message/tool event arrived
|
||||
const ctx = createMockContext()
|
||||
|
||||
@@ -193,6 +193,9 @@ async function getMainSessionStatus(
|
||||
statusesRes,
|
||||
{} as Record<string, { type?: string }>
|
||||
)
|
||||
if (!(ctx.sessionID in statuses)) {
|
||||
return "idle"
|
||||
}
|
||||
const status = statuses[ctx.sessionID]?.type
|
||||
if (status === "idle" || status === "busy" || status === "retry") {
|
||||
return status
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "../../config"
|
||||
import { resolveRunAgent } from "./agent-resolver"
|
||||
import { getAgentRuntimeName } from "../../shared/agent-display-names"
|
||||
|
||||
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig =>
|
||||
OhMyOpenCodeConfigSchema.parse(overrides)
|
||||
@@ -31,7 +32,7 @@ describe("resolveRunAgent", () => {
|
||||
)
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Hephaestus - Deep Agent")
|
||||
expect(agent).toBe(getAgentRuntimeName("hephaestus"))
|
||||
})
|
||||
|
||||
it("uses env agent over config", () => {
|
||||
@@ -43,7 +44,7 @@ describe("resolveRunAgent", () => {
|
||||
const agent = resolveRunAgent({ message: "test" }, config, env)
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Atlas - Plan Executor")
|
||||
expect(agent).toBe(getAgentRuntimeName("atlas"))
|
||||
})
|
||||
|
||||
it("uses config agent over default", () => {
|
||||
@@ -54,7 +55,7 @@ describe("resolveRunAgent", () => {
|
||||
const agent = resolveRunAgent({ message: "test" }, config, {})
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Prometheus - Plan Builder")
|
||||
expect(agent).toBe(getAgentRuntimeName("prometheus"))
|
||||
})
|
||||
|
||||
it("falls back to sisyphus when none set", () => {
|
||||
@@ -65,7 +66,7 @@ describe("resolveRunAgent", () => {
|
||||
const agent = resolveRunAgent({ message: "test" }, config, {})
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Sisyphus - Ultraworker")
|
||||
expect(agent).toBe(getAgentRuntimeName("sisyphus"))
|
||||
})
|
||||
|
||||
it("skips disabled sisyphus for next available core agent", () => {
|
||||
@@ -76,10 +77,10 @@ describe("resolveRunAgent", () => {
|
||||
const agent = resolveRunAgent({ message: "test" }, config, {})
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Hephaestus - Deep Agent")
|
||||
expect(agent).toBe(getAgentRuntimeName("hephaestus"))
|
||||
})
|
||||
|
||||
it("maps display-name style default_run_agent values to canonical display names", () => {
|
||||
it("maps display-name style default_run_agent values to canonical runtime names", () => {
|
||||
// given
|
||||
const config = createConfig({ default_run_agent: "Sisyphus - Ultraworker" })
|
||||
|
||||
@@ -87,7 +88,7 @@ describe("resolveRunAgent", () => {
|
||||
const agent = resolveRunAgent({ message: "test" }, config, {})
|
||||
|
||||
// then
|
||||
expect(agent).toBe("Sisyphus - Ultraworker")
|
||||
expect(agent).toBe(getAgentRuntimeName("sisyphus"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun
|
||||
import * as originalSdk from "@opencode-ai/sdk"
|
||||
import * as originalPortUtils from "../../shared/port-utils"
|
||||
import * as originalBinaryResolver from "./opencode-binary-resolver"
|
||||
import * as originalServerAuth from "../../shared/opencode-server-auth"
|
||||
|
||||
const originalConsole = globalThis.console
|
||||
|
||||
@@ -13,11 +14,15 @@ const mockCreateOpencode = mock(() =>
|
||||
server: { url: "http://127.0.0.1:4096", close: mockServerClose },
|
||||
})
|
||||
)
|
||||
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
||||
const mockCreateOpencodeClient = mock((options?: { baseUrl?: string }) => ({
|
||||
session: {},
|
||||
baseUrl: options?.baseUrl,
|
||||
}))
|
||||
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
||||
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
||||
const mockConsoleLog = mock(() => {})
|
||||
const mockWithWorkingOpencodePath = mock((startServer: () => Promise<unknown>) => startServer())
|
||||
const mockInjectServerAuthIntoClient = mock(() => {})
|
||||
|
||||
mock.module("@opencode-ai/sdk", () => ({
|
||||
createOpencode: mockCreateOpencode,
|
||||
@@ -34,10 +39,15 @@ mock.module("./opencode-binary-resolver", () => ({
|
||||
withWorkingOpencodePath: mockWithWorkingOpencodePath,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-server-auth", () => ({
|
||||
injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.module("@opencode-ai/sdk", () => originalSdk)
|
||||
mock.module("../../shared/port-utils", () => originalPortUtils)
|
||||
mock.module("./opencode-binary-resolver", () => originalBinaryResolver)
|
||||
mock.module("../../shared/opencode-server-auth", () => originalServerAuth)
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
@@ -52,6 +62,7 @@ describe("createServerConnection", () => {
|
||||
mockServerClose.mockClear()
|
||||
mockConsoleLog.mockClear()
|
||||
mockWithWorkingOpencodePath.mockClear()
|
||||
mockInjectServerAuthIntoClient.mockClear()
|
||||
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
||||
})
|
||||
|
||||
@@ -59,6 +70,49 @@ describe("createServerConnection", () => {
|
||||
globalThis.console = originalConsole
|
||||
})
|
||||
|
||||
it("attach mode injects auth only for loopback URLs", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// when
|
||||
const localhostResult = await createServerConnection({ attach: "http://localhost:8080", signal })
|
||||
const loopbackResult = await createServerConnection({ attach: "http://127.0.0.1:8080", signal })
|
||||
const anyBindResult = await createServerConnection({ attach: "http://0.0.0.0:8080", signal })
|
||||
const remoteResult = await createServerConnection({ attach: "https://example.com", signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://localhost:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://0.0.0.0:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "https://example.com" })
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenCalledTimes(3)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(1, localhostResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(2, loopbackResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(3, anyBindResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalledWith(remoteResult.client)
|
||||
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
|
||||
localhostResult.cleanup()
|
||||
loopbackResult.cleanup()
|
||||
anyBindResult.cleanup()
|
||||
remoteResult.cleanup()
|
||||
expect(mockServerClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("attach mode skips auth injection for invalid attach URLs", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
const attachUrl = "not-a-url"
|
||||
|
||||
// when
|
||||
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalled()
|
||||
result.cleanup()
|
||||
expect(mockServerClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("attach mode returns client with no-op cleanup", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
@@ -68,8 +122,6 @@ describe("createServerConnection", () => {
|
||||
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
|
||||
expect(result.client).toBeDefined()
|
||||
expect(result.cleanup).toBeDefined()
|
||||
result.cleanup()
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import pc from "picocolors"
|
||||
import type { ServerConnection } from "./types"
|
||||
import { injectServerAuthIntoClient } from "../../shared/opencode-server-auth"
|
||||
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
||||
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"])
|
||||
|
||||
function isLoopbackAttachUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return LOOPBACK_HOSTS.has(parsed.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPortStartFailure(error: unknown, port: number): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
@@ -40,6 +52,9 @@ export async function createServerConnection(options: {
|
||||
if (attach !== undefined) {
|
||||
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
|
||||
const client = createOpencodeClient({ baseUrl: attach })
|
||||
if (isLoopbackAttachUrl(attach)) {
|
||||
injectServerAuthIntoClient(client)
|
||||
}
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
@@ -66,12 +81,14 @@ export async function createServerConnection(options: {
|
||||
|
||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
}
|
||||
|
||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
@@ -93,6 +110,7 @@ export async function createServerConnection(options: {
|
||||
|
||||
console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
||||
message: "Do you have access to OpenCode Zen (opencode/ models)?",
|
||||
options: [
|
||||
{ value: "no", label: "No", hint: "Will use other configured providers" },
|
||||
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.4, etc." },
|
||||
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-7, opencode/gpt-5.4, etc." },
|
||||
],
|
||||
initialValue: initial.opencodeZen,
|
||||
})
|
||||
@@ -110,6 +110,16 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
||||
})
|
||||
if (!opencodeGo) return null
|
||||
|
||||
const vercelAiGateway = await selectOrCancel({
|
||||
message: "Do you have a Vercel AI Gateway API key?",
|
||||
options: [
|
||||
{ value: "no", label: "No", hint: "Will use other configured providers" },
|
||||
{ value: "yes", label: "Yes", hint: "Universal proxy for OpenAI, Anthropic, Google, etc." },
|
||||
],
|
||||
initialValue: initial.vercelAiGateway,
|
||||
})
|
||||
if (!vercelAiGateway) return null
|
||||
|
||||
return {
|
||||
hasClaude: claude !== "no",
|
||||
isMax20: claude === "max20",
|
||||
@@ -120,5 +130,6 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
||||
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
||||
hasKimiForCoding: kimiForCoding === "yes",
|
||||
hasOpencodeGo: opencodeGo === "yes",
|
||||
hasVercelAiGateway: vercelAiGateway === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("runTuiInstaller", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
|
||||
@@ -92,6 +93,7 @@ describe("runTuiInstaller", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
|
||||
@@ -105,6 +107,7 @@ describe("runTuiInstaller", () => {
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
hasVercelAiGateway: false,
|
||||
}),
|
||||
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||
success: true,
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
|
||||
)
|
||||
}
|
||||
|
||||
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
|
||||
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen && !config.hasVercelAiGateway) {
|
||||
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface InstallArgs {
|
||||
zaiCodingPlan?: BooleanArg
|
||||
kimiForCoding?: BooleanArg
|
||||
opencodeGo?: BooleanArg
|
||||
vercelAiGateway?: BooleanArg
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface InstallConfig {
|
||||
hasZaiCodingPlan: boolean
|
||||
hasKimiForCoding: boolean
|
||||
hasOpencodeGo: boolean
|
||||
hasVercelAiGateway: boolean
|
||||
}
|
||||
|
||||
export interface ConfigMergeResult {
|
||||
@@ -44,4 +46,5 @@ export interface DetectedConfig {
|
||||
hasZaiCodingPlan: boolean
|
||||
hasKimiForCoding: boolean
|
||||
hasOpencodeGo: boolean
|
||||
hasVercelAiGateway: boolean
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/config/ — Zod v4 Schema System
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const AgentDefinitionPathSchema = z.string().min(1)
|
||||
|
||||
export const AgentDefinitionsConfigSchema = z.array(AgentDefinitionPathSchema).optional()
|
||||
@@ -27,30 +27,6 @@ describe("BackgroundTaskConfigSchema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("maxDescendants", () => {
|
||||
describe("#given valid maxDescendants (50)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 })
|
||||
|
||||
expect(result.maxDescendants).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxDescendants below minimum (0)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxDescendants: 0 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncPollTimeoutMs", () => {
|
||||
describe("#given valid syncPollTimeoutMs (120000)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ export const BackgroundTaskConfigSchema = z.object({
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
maxDepth: z.number().int().min(1).optional(),
|
||||
maxDescendants: z.number().int().min(1).optional(),
|
||||
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
||||
staleTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { AnyMcpNameSchema } from "../../mcp/types"
|
||||
import { BuiltinSkillNameSchema } from "./agent-names"
|
||||
import { AgentDefinitionsConfigSchema } from "./agent-definitions"
|
||||
import { AgentOverridesSchema } from "./agent-overrides"
|
||||
import { BabysittingConfigSchema } from "./babysitting"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
@@ -29,6 +30,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
new_task_system_enabled: z.boolean().optional(),
|
||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||
default_run_agent: z.string().optional(),
|
||||
/** Paths to external agent definition files (.md or .json) */
|
||||
agent_definitions: AgentDefinitionsConfigSchema,
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(z.string()).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
|
||||
import type { HookName, OhMyOpenCodeConfig } from "./config"
|
||||
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
||||
import type { BackgroundManager } from "./features/background-agent"
|
||||
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||
import type { PluginContext } from "./plugin/types"
|
||||
import type { ModelCacheState } from "./plugin-state"
|
||||
|
||||
@@ -36,6 +37,7 @@ export function createHooks(args: {
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
backgroundManager: BackgroundManager
|
||||
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
mergedSkills: LoadedSkill[]
|
||||
@@ -46,6 +48,7 @@ export function createHooks(args: {
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
backgroundManager,
|
||||
modelFallbackControllerAccessor,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
mergedSkills,
|
||||
@@ -56,6 +59,7 @@ export function createHooks(args: {
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
modelFallbackControllerAccessor,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types"
|
||||
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
|
||||
import { BackgroundManager } from "./features/background-agent"
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager"
|
||||
import { createModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||
import { initTaskToastManager } from "./features/task-toast-manager"
|
||||
import { TmuxSessionManager } from "./features/tmux-subagent"
|
||||
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
|
||||
@@ -12,6 +13,7 @@ import { registerManagerForCleanup } from "./features/background-agent/process-c
|
||||
import { createConfigHandler } from "./plugin-handlers"
|
||||
import { log } from "./shared"
|
||||
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
|
||||
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||
|
||||
type CreateManagersDeps = {
|
||||
BackgroundManagerClass: typeof BackgroundManager
|
||||
@@ -38,6 +40,7 @@ export type Managers = {
|
||||
backgroundManager: BackgroundManager
|
||||
skillMcpManager: SkillMcpManager
|
||||
configHandler: ReturnType<typeof createConfigHandler>
|
||||
modelFallbackControllerAccessor: ModelFallbackControllerAccessor
|
||||
}
|
||||
|
||||
export function createManagers(args: {
|
||||
@@ -119,11 +122,13 @@ export function createManagers(args: {
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
})
|
||||
const modelFallbackControllerAccessor = createModelFallbackControllerAccessor()
|
||||
|
||||
return {
|
||||
tmuxSessionManager,
|
||||
backgroundManager,
|
||||
skillMcpManager,
|
||||
configHandler,
|
||||
modelFallbackControllerAccessor,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ type CreateToolsResult = {
|
||||
export async function createTools(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
|
||||
}): Promise<CreateToolsResult> {
|
||||
const { ctx, pluginConfig, managers } = args
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/features/ — 19 Feature Modules
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Both must agree before marking a task complete. Prevents premature completion on
|
||||
|
||||
## CONCURRENCY MODEL
|
||||
|
||||
- Key format: `{providerID}/{modelID}` (e.g., `anthropic/claude-opus-4-6`)
|
||||
- Key format: `{providerID}/{modelID}` (e.g., `anthropic/claude-opus-4-7`)
|
||||
- Default limit: 5 concurrent per key (configurable via `background_task` config)
|
||||
- FIFO queue: tasks wait in order when slots full
|
||||
- Slot released on: completion, error, cancellation
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
// given
|
||||
const message = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(message))
|
||||
|
||||
@@ -94,18 +94,18 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
expect(result?.model?.providerID).toBe("anthropic")
|
||||
expect(result?.model?.modelID).toBe("claude-opus-4-6")
|
||||
expect(result?.model?.modelID).toBe("claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("skips compaction agent messages", () => {
|
||||
// given
|
||||
const compactionMessage = {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}
|
||||
const validMessage = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify(compactionMessage))
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(validMessage))
|
||||
@@ -125,12 +125,12 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
|
||||
id: compactionMessageID,
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}))
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
|
||||
id: "msg_001",
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}))
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
|
||||
// when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-7")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
|
||||
|
||||
// then
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
|
||||
export { waitForTaskSessionID } from "./wait-for-task-session"
|
||||
export type { WaitForTaskSessionIDOptions } from "./wait-for-task-session"
|
||||
|
||||
@@ -855,7 +855,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
{
|
||||
info: {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -890,7 +890,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
|
||||
//#then
|
||||
expect(capturedBody?.agent).toBe("sisyphus")
|
||||
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
|
||||
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
@@ -913,7 +913,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
}
|
||||
const currentMessage: CurrentMessage = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -921,7 +921,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
|
||||
// then - uses currentMessage values, not task.parentModel/parentAgent
|
||||
expect(promptBody.agent).toBe("sisyphus")
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
|
||||
})
|
||||
|
||||
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
|
||||
@@ -1155,7 +1155,7 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4.6",
|
||||
modelID: "claude-opus-4.7",
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
@@ -1211,7 +1211,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4.6",
|
||||
modelID: "claude-opus-4.7",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
@@ -1231,7 +1231,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6", variant: "high" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7", variant: "high" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
@@ -1272,7 +1272,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
@@ -1349,7 +1349,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should release concurrency and clear key on completion", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
@@ -1378,7 +1378,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should prevent double completion and double release", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
@@ -1508,7 +1508,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should release task concurrencyKey when startTask throws after assigning it", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
|
||||
const task = createMockTask({
|
||||
@@ -1524,7 +1524,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
@@ -1544,7 +1544,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should mark task as error when startTask throws after session creation", async () => {
|
||||
//#given - startTask creates session but fails before sending prompt
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7"
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-zombie-session",
|
||||
@@ -1561,7 +1561,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
@@ -1585,7 +1585,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should release queue slot when queued task is already interrupt", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
|
||||
const task = createMockTask({
|
||||
@@ -1601,7 +1601,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
@@ -2104,7 +2104,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
agent: "test-agent",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
}
|
||||
const launchInputWithoutModel = {
|
||||
description: "Test task without model",
|
||||
@@ -2124,7 +2124,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(taskWithModel.status).toBe("pending")
|
||||
expect(taskWithoutModel.status).toBe("pending")
|
||||
expect(promptBodies).toHaveLength(2)
|
||||
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
|
||||
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
|
||||
expect(promptBodies[0].agent).toBe("test-agent")
|
||||
expect(promptBodies[1].agent).toBe("test-agent")
|
||||
expect("model" in promptBodies[1]).toBe(false)
|
||||
@@ -2327,7 +2327,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
await expect(result).rejects.toThrow("background_task.maxDepth=3")
|
||||
})
|
||||
|
||||
test("should block launches when maxDescendants is reached", async () => {
|
||||
test("allows multiple descendants without a root spawn cap", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
@@ -2337,7 +2337,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2354,10 +2353,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
|
||||
await expect(result).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should consume descendant quota for reserved sync spawns", async () => {
|
||||
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
@@ -2367,7 +2366,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
await manager.reserveSubagentSpawn("session-root")
|
||||
@@ -2376,7 +2374,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.assertCanSpawn("session-root")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
|
||||
await expect(result).resolves.toMatchObject({
|
||||
rootSessionID: "session-root",
|
||||
childDepth: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test("should fail closed when session lineage lookup fails", async () => {
|
||||
@@ -2392,7 +2393,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2407,10 +2407,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
|
||||
await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely")
|
||||
})
|
||||
|
||||
test("should release descendant quota when queued task is cancelled before session starts", async () => {
|
||||
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
@@ -2420,7 +2420,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ defaultConcurrency: 1, maxDescendants: 2 },
|
||||
{ defaultConcurrency: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2445,7 +2445,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(replacementTask.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("should release descendant quota when session creation fails before session starts", async () => {
|
||||
test("allows retry after session creation fails before session starts", async () => {
|
||||
// given
|
||||
let createAttempts = 0
|
||||
manager.shutdown()
|
||||
@@ -2472,7 +2472,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2887,7 +2886,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("should release descendant quota when task completes", async () => {
|
||||
test("allows relaunch after task completes", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
@@ -2896,7 +2895,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
@@ -2920,7 +2918,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when running task is cancelled", async () => {
|
||||
test("allows relaunch after running task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
@@ -2929,7 +2927,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2950,7 +2947,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when task errors", async () => {
|
||||
test("allows relaunch after task errors", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
@@ -2959,7 +2956,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2984,7 +2980,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should not double-decrement quota when pending task is cancelled", async () => {
|
||||
test("allows repeated relaunch after pending tasks are cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
@@ -2993,7 +2989,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 2 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3250,7 +3245,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
description: "Task 1",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
@@ -4230,7 +4225,7 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
|
||||
describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
const defaultRetryFallbackChain = [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
|
||||
{ providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" },
|
||||
]
|
||||
|
||||
@@ -4254,7 +4249,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
agent: "sisyphus",
|
||||
status: "running",
|
||||
concurrencyKey: input.concurrencyKey,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.6-thinking" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4.7-thinking" },
|
||||
fallbackChain: input.fallbackChain ?? defaultRetryFallbackChain,
|
||||
attemptCount: 0,
|
||||
})
|
||||
@@ -4399,7 +4394,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "anthropic/claude-opus-4.6-thinking"
|
||||
const concurrencyKey = "anthropic/claude-opus-4.7-thinking"
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
stubProcessKey(manager)
|
||||
@@ -4411,7 +4406,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
description: "task that should retry",
|
||||
concurrencyKey,
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
|
||||
],
|
||||
})
|
||||
@@ -4425,7 +4420,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message:
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.6-thinking\"}}",
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-thinking\"}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -4436,7 +4431,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
expect(task.attemptCount).toBe(1)
|
||||
expect(task.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4.6",
|
||||
modelID: "claude-opus-4.7",
|
||||
variant: "max",
|
||||
})
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
@@ -4474,7 +4469,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
expect(task.attemptCount).toBe(1)
|
||||
expect(task.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4.6",
|
||||
modelID: "claude-opus-4.7",
|
||||
variant: "max",
|
||||
})
|
||||
|
||||
@@ -4502,7 +4497,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message:
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.6-thinking\"}}",
|
||||
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-thinking\"}}",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -4519,7 +4514,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
expect(task.attemptCount).toBe(1)
|
||||
expect(task.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4.6",
|
||||
modelID: "claude-opus-4.7",
|
||||
variant: "max",
|
||||
})
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ import {
|
||||
} from "./loop-detector"
|
||||
import {
|
||||
createSubagentDepthLimitError,
|
||||
createSubagentDescendantLimitError,
|
||||
getMaxRootSessionSpawnBudget,
|
||||
getMaxSubagentDepth,
|
||||
resolveSubagentSpawnContext,
|
||||
type SubagentSpawnContext,
|
||||
@@ -218,16 +216,6 @@ export class BackgroundManager {
|
||||
})
|
||||
}
|
||||
|
||||
const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
|
||||
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
|
||||
if (descendantCount >= maxRootSessionSpawnBudget) {
|
||||
throw createSubagentDescendantLimitError({
|
||||
rootSessionID: spawnContext.rootSessionID,
|
||||
descendantCount,
|
||||
maxDescendants: maxRootSessionSpawnBudget,
|
||||
})
|
||||
}
|
||||
|
||||
return spawnContext
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
type ProcessCleanupEvent =
|
||||
| NodeJS.Signals
|
||||
| "beforeExit"
|
||||
| "exit"
|
||||
| "uncaughtException"
|
||||
| "unhandledRejection"
|
||||
|
||||
export function getNewListener(
|
||||
signal: ProcessCleanupEvent,
|
||||
existingListeners: Function[],
|
||||
): () => void {
|
||||
const listener = process
|
||||
.listeners(signal)
|
||||
.find((registeredListener) => !existingListeners.includes(registeredListener))
|
||||
|
||||
if (typeof listener !== "function") {
|
||||
throw new Error(`Expected a ${signal} listener to be registered`)
|
||||
}
|
||||
|
||||
return listener
|
||||
}
|
||||
|
||||
export async function flushMicrotasks(): Promise<void> {
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
|
||||
import {
|
||||
@@ -5,42 +7,17 @@ import {
|
||||
registerManagerForCleanup,
|
||||
unregisterManagerForCleanup,
|
||||
} from "./process-cleanup"
|
||||
import { flushMicrotasks, getNewListener } from "./process-cleanup.test-helpers"
|
||||
|
||||
type CleanupManager = {
|
||||
shutdown: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
|
||||
|
||||
function getNewListener(
|
||||
signal: ProcessCleanupEvent,
|
||||
existingListeners: Function[],
|
||||
): () => void {
|
||||
const listener = process
|
||||
.listeners(signal)
|
||||
.find((registeredListener) => !existingListeners.includes(registeredListener))
|
||||
|
||||
expect(listener).toBeDefined()
|
||||
|
||||
if (typeof listener !== "function") {
|
||||
throw new Error(`Expected a ${signal} listener to be registered`)
|
||||
}
|
||||
|
||||
return listener
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe("#given process cleanup registration", () => {
|
||||
const registeredManagers: CleanupManager[] = []
|
||||
const originalExitCode = process.exitCode
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
process.exitCode = 0
|
||||
registeredManagers.length = 0
|
||||
_resetForTesting()
|
||||
})
|
||||
@@ -50,7 +27,7 @@ describe("#given process cleanup registration", () => {
|
||||
unregisterManagerForCleanup(manager)
|
||||
}
|
||||
|
||||
process.exitCode = originalExitCode
|
||||
process.exitCode = 0
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
@@ -92,13 +69,7 @@ describe("#given process cleanup registration", () => {
|
||||
|
||||
test("#when cleanup finishes after SIGINT #then the fallback exit timer is cleared", async () => {
|
||||
const sigintListenersBefore = process.listeners("SIGINT")
|
||||
const timeoutHandle = setTimeout(() => undefined, 0)
|
||||
clearTimeout(timeoutHandle)
|
||||
|
||||
const setTimeoutImplementation: typeof setTimeout = () => timeoutHandle
|
||||
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
|
||||
setTimeoutImplementation,
|
||||
)
|
||||
const setTimeoutSpy = spyOn(globalThis, "setTimeout")
|
||||
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
|
||||
|
||||
try {
|
||||
@@ -117,11 +88,10 @@ describe("#given process cleanup registration", () => {
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1)
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle)
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore()
|
||||
clearTimeoutSpy.mockRestore()
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -163,6 +133,32 @@ describe("#given process cleanup registration", () => {
|
||||
|
||||
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersAfterFirstRegistration)
|
||||
})
|
||||
|
||||
test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => {
|
||||
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
|
||||
throw new Error(`Unexpected process.exit(${String(code)})`)
|
||||
})
|
||||
const shutdownOne = mock(() => {})
|
||||
const shutdownTwo = mock(() => {})
|
||||
const managerOne = { shutdown: shutdownOne }
|
||||
const managerTwo = { shutdown: shutdownTwo }
|
||||
registeredManagers.push(managerOne, managerTwo)
|
||||
|
||||
try {
|
||||
registerManagerForCleanup(managerOne)
|
||||
registerManagerForCleanup(managerTwo)
|
||||
|
||||
process.emit("uncaughtException", new Error("boom"))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(shutdownOne).toHaveBeenCalledTimes(1)
|
||||
expect(shutdownTwo).toHaveBeenCalledTimes(1)
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(exitSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
exitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cleanup managers are unregistered", () => {
|
||||
@@ -202,5 +198,88 @@ describe("#given process cleanup registration", () => {
|
||||
expect(remainingManagerShutdown).toHaveBeenCalledTimes(1)
|
||||
expect(removedManagerShutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("#given uncaughtException handler registered #when manager is unregistered via unregisterManagerForCleanup #then subsequent events do not invoke that manager", () => {
|
||||
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
|
||||
const shutdown = mock(() => {})
|
||||
const manager = { shutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
registerManagerForCleanup(manager)
|
||||
expect(process.listeners("uncaughtException")).toHaveLength(
|
||||
uncaughtExceptionListenersBefore.length + 1,
|
||||
)
|
||||
|
||||
unregisterManagerForCleanup(manager)
|
||||
registeredManagers.length = 0
|
||||
process.emit("uncaughtException", new Error("boom"))
|
||||
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given uncaught exception and rejection cleanup", () => {
|
||||
test("#given manager registered AND process emits uncaughtException #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
|
||||
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
|
||||
throw new Error(`Unexpected process.exit(${String(code)})`)
|
||||
})
|
||||
const shutdown = mock(() => {})
|
||||
const manager = { shutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
try {
|
||||
registerManagerForCleanup(manager)
|
||||
|
||||
process.emit("uncaughtException", new Error("boom"))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(shutdown).toHaveBeenCalledTimes(1)
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(exitSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
exitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
|
||||
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
|
||||
throw new Error(`Unexpected process.exit(${String(code)})`)
|
||||
})
|
||||
const shutdown = mock(() => {})
|
||||
const manager = { shutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
try {
|
||||
registerManagerForCleanup(manager)
|
||||
|
||||
process.emit("unhandledRejection", new Error("boom"), Promise.resolve())
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(shutdown).toHaveBeenCalledTimes(1)
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(exitSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
exitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("#given _resetForTesting() called #when event fires #then no cleanup runs", () => {
|
||||
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
|
||||
const shutdown = mock(() => {})
|
||||
const manager = { shutdown }
|
||||
|
||||
registerManagerForCleanup(manager)
|
||||
expect(process.listeners("uncaughtException")).toHaveLength(
|
||||
uncaughtExceptionListenersBefore.length + 1,
|
||||
)
|
||||
|
||||
_resetForTesting()
|
||||
process.emit("uncaughtException", new Error("boom"))
|
||||
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(process.listeners("uncaughtException")).toHaveLength(
|
||||
uncaughtExceptionListenersBefore.length,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
|
||||
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
|
||||
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
|
||||
|
||||
function scheduleForcedExit(cleanupResult: void | Promise<void>, exitCode: number): void {
|
||||
process.exitCode = exitCode
|
||||
const exitTimeout = setTimeout(() => process.exit(), 6000)
|
||||
void Promise.resolve(cleanupResult).finally(() => {
|
||||
clearTimeout(exitTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
function registerProcessSignal(
|
||||
signal: ProcessCleanupEvent,
|
||||
signal: ProcessCleanupSignal,
|
||||
handler: () => void | Promise<void>,
|
||||
exitAfter: boolean
|
||||
): () => void {
|
||||
const listener = () => {
|
||||
const cleanupResult = handler()
|
||||
if (exitAfter) {
|
||||
process.exitCode = 0
|
||||
const exitTimeout = setTimeout(() => process.exit(), 6000)
|
||||
void Promise.resolve(cleanupResult).finally(() => {
|
||||
clearTimeout(exitTimeout)
|
||||
})
|
||||
scheduleForcedExit(cleanupResult, 0)
|
||||
}
|
||||
}
|
||||
process.on(signal, listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
function registerErrorEvent(
|
||||
signal: ProcessCleanupErrorEvent,
|
||||
handler: (error: unknown) => void | Promise<void>
|
||||
): (error: unknown) => void {
|
||||
const listener = (error: unknown) => {
|
||||
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
|
||||
scheduleForcedExit(handler(error), 1)
|
||||
}
|
||||
process.on(signal, listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
interface CleanupTarget {
|
||||
shutdown(): void | Promise<void>
|
||||
}
|
||||
|
||||
const cleanupManagers = new Set<CleanupTarget>()
|
||||
let cleanupRegistered = false
|
||||
const cleanupHandlers = new Map<ProcessCleanupEvent, () => void>()
|
||||
const cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
|
||||
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
|
||||
|
||||
export function registerManagerForCleanup(manager: CleanupTarget): void {
|
||||
cleanupManagers.add(manager)
|
||||
@@ -59,9 +77,9 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
|
||||
return cleanupPromise
|
||||
}
|
||||
|
||||
const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => {
|
||||
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
|
||||
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
|
||||
cleanupHandlers.set(signal, listener)
|
||||
cleanupSignalHandlers.set(signal, listener)
|
||||
}
|
||||
|
||||
registerSignal("SIGINT", true)
|
||||
@@ -71,6 +89,8 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
|
||||
}
|
||||
registerSignal("beforeExit", false)
|
||||
registerSignal("exit", false)
|
||||
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException", cleanupAll))
|
||||
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection", cleanupAll))
|
||||
}
|
||||
|
||||
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
|
||||
@@ -78,10 +98,14 @@ export function unregisterManagerForCleanup(manager: CleanupTarget): void {
|
||||
|
||||
if (cleanupManagers.size > 0) return
|
||||
|
||||
for (const [signal, listener] of cleanupHandlers.entries()) {
|
||||
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
|
||||
process.off(signal, listener)
|
||||
}
|
||||
cleanupHandlers.clear()
|
||||
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
|
||||
process.off(signal, listener)
|
||||
}
|
||||
cleanupSignalHandlers.clear()
|
||||
cleanupErrorHandlers.clear()
|
||||
cleanupRegistered = false
|
||||
}
|
||||
|
||||
@@ -90,9 +114,13 @@ export function _resetForTesting(): void {
|
||||
for (const manager of [...cleanupManagers]) {
|
||||
cleanupManagers.delete(manager)
|
||||
}
|
||||
for (const [signal, listener] of cleanupHandlers.entries()) {
|
||||
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
|
||||
process.off(signal, listener)
|
||||
}
|
||||
cleanupHandlers.clear()
|
||||
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
|
||||
process.off(signal, listener)
|
||||
}
|
||||
cleanupSignalHandlers.clear()
|
||||
cleanupErrorHandlers.clear()
|
||||
cleanupRegistered = false
|
||||
}
|
||||
|
||||
@@ -577,3 +577,84 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||
})
|
||||
})
|
||||
|
||||
describe("background-agent spawner tmux callback ordering", () => {
|
||||
test("fires promptAsync before tmux callback resolves (no blocking)", async () => {
|
||||
//#given
|
||||
const events: string[] = []
|
||||
let resolveTmuxCallback: () => void = () => {}
|
||||
const tmuxCallbackPromise = new Promise<void>((resolve) => {
|
||||
resolveTmuxCallback = resolve
|
||||
})
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/tmp/test" } }),
|
||||
create: async () => {
|
||||
events.push("session.create")
|
||||
return { data: { id: "ses_blocking_tmux" } }
|
||||
},
|
||||
promptAsync: async () => {
|
||||
events.push("promptAsync")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const onSubagentSessionCreated = mock(async () => {
|
||||
events.push("tmux.callback.start")
|
||||
await tmuxCallbackPromise
|
||||
events.push("tmux.callback.end")
|
||||
})
|
||||
|
||||
const task = createTask({
|
||||
description: "Blocking tmux test",
|
||||
prompt: "Do work",
|
||||
agent: "general",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
})
|
||||
|
||||
const item = {
|
||||
task,
|
||||
input: {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
},
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
client,
|
||||
directory: "/tmp/test",
|
||||
concurrencyManager: { release: () => {} },
|
||||
tmuxEnabled: true,
|
||||
onSubagentSessionCreated,
|
||||
onTaskError: () => {},
|
||||
}
|
||||
|
||||
const originalTmux = process.env.TMUX
|
||||
process.env.TMUX = "/tmp/fake-tmux-socket"
|
||||
|
||||
try {
|
||||
//#when
|
||||
await startTask(item as any, ctx as any)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
//#then
|
||||
expect(events).toContain("session.create")
|
||||
expect(events).toContain("promptAsync")
|
||||
expect(events).toContain("tmux.callback.start")
|
||||
const promptIdx = events.indexOf("promptAsync")
|
||||
const tmuxStartIdx = events.indexOf("tmux.callback.start")
|
||||
expect(promptIdx < tmuxStartIdx).toBe(true)
|
||||
expect(events).not.toContain("tmux.callback.end")
|
||||
} finally {
|
||||
resolveTmuxCallback()
|
||||
if (originalTmux === undefined) delete process.env.TMUX
|
||||
else process.env.TMUX = originalTmux
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -115,29 +114,6 @@ export async function startTask(
|
||||
const sessionID = createResult.data.id
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", err)
|
||||
})
|
||||
log("[background-agent] tmux callback completed, waiting")
|
||||
await new Promise(r => setTimeout(r, TMUX_CALLBACK_DELAY_MS))
|
||||
} else {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
}
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
@@ -188,7 +164,8 @@ export async function startTask(
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
|
||||
const promptChain = promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: promptBody,
|
||||
}).catch(async (error) => {
|
||||
@@ -214,6 +191,29 @@ export async function startTask(
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
|
||||
void promptChain
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
log("[background-agent] Invoking tmux callback (fire-and-forget)", { sessionID })
|
||||
void onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", err)
|
||||
})
|
||||
} else {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeTask(
|
||||
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
getMaxSubagentDepth,
|
||||
DEFAULT_MAX_SUBAGENT_DEPTH,
|
||||
createSubagentDepthLimitError,
|
||||
createSubagentDescendantLimitError,
|
||||
getMaxRootSessionSpawnBudget,
|
||||
DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET,
|
||||
} from "./subagent-spawn-limits"
|
||||
|
||||
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
|
||||
@@ -62,7 +59,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*lookup failed/)
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDepth cannot be enforced safely.*lookup failed/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,7 +74,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/)
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDepth cannot be enforced safely.*No session data returned/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,20 +206,6 @@ describe("getMaxSubagentDepth", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxRootSessionSpawnBudget", () => {
|
||||
test("returns DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET when no config", () => {
|
||||
expect(getMaxRootSessionSpawnBudget()).toBe(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET)
|
||||
})
|
||||
|
||||
test("returns config.maxDescendants when provided", () => {
|
||||
expect(getMaxRootSessionSpawnBudget({ maxDescendants: 10 })).toBe(10)
|
||||
})
|
||||
|
||||
test("default is 50", () => {
|
||||
expect(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSubagentDepthLimitError", () => {
|
||||
test("includes childDepth, maxDepth, and session IDs in message", () => {
|
||||
const error = createSubagentDepthLimitError({
|
||||
@@ -239,18 +222,3 @@ describe("createSubagentDepthLimitError", () => {
|
||||
expect(error.message).toContain("spawn blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSubagentDescendantLimitError", () => {
|
||||
test("includes descendant count, max, and root session ID", () => {
|
||||
const error = createSubagentDescendantLimitError({
|
||||
rootSessionID: "root-789",
|
||||
descendantCount: 50,
|
||||
maxDescendants: 50,
|
||||
})
|
||||
|
||||
expect(error.message).toContain("root-789")
|
||||
expect(error.message).toContain("50")
|
||||
expect(error.message).toContain("maxDescendants=50")
|
||||
expect(error.message).toContain("spawn blocked")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import type { OpencodeClient } from "./constants"
|
||||
|
||||
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
|
||||
export const DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET = 50
|
||||
|
||||
export interface SubagentSpawnContext {
|
||||
rootSessionID: string
|
||||
@@ -14,10 +13,6 @@ export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
|
||||
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
|
||||
}
|
||||
|
||||
export function getMaxRootSessionSpawnBudget(config?: BackgroundTaskConfig): number {
|
||||
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET
|
||||
}
|
||||
|
||||
export async function resolveSubagentSpawnContext(
|
||||
client: OpencodeClient,
|
||||
parentSessionID: string,
|
||||
@@ -53,7 +48,7 @@ export async function resolveSubagentSpawnContext(
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDescendants cannot be enforced safely. ${reason}`
|
||||
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDepth cannot be enforced safely. ${reason}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,14 +79,3 @@ export function createSubagentDepthLimitError(input: {
|
||||
`Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.`
|
||||
)
|
||||
}
|
||||
|
||||
export function createSubagentDescendantLimitError(input: {
|
||||
rootSessionID: string
|
||||
descendantCount: number
|
||||
maxDescendants: number
|
||||
}): Error {
|
||||
const { rootSessionID, descendantCount, maxDescendants } = input
|
||||
return new Error(
|
||||
`Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
concurrencyKey: "anthropic/claude-opus-4-6",
|
||||
concurrencyKey: "anthropic/claude-opus-4-7",
|
||||
})
|
||||
|
||||
//#when
|
||||
@@ -661,7 +661,7 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
|
||||
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-7")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { BackgroundTaskStatus } from "./types"
|
||||
import { waitForTaskSessionID } from "./wait-for-task-session"
|
||||
|
||||
interface TaskSnapshot {
|
||||
sessionID?: string
|
||||
status?: BackgroundTaskStatus
|
||||
}
|
||||
|
||||
function createManager(responses: TaskSnapshot[]) {
|
||||
let index = 0
|
||||
|
||||
return {
|
||||
getTask(_taskID: string): TaskSnapshot {
|
||||
const response = responses[Math.min(index, responses.length - 1)]
|
||||
index += 1
|
||||
return response
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("waitForTaskSessionID", () => {
|
||||
test("#given task already has a session id #when waiting #then it returns immediately", async () => {
|
||||
// given
|
||||
const manager = createManager([{ sessionID: "ses_ready_123", status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_ready")
|
||||
|
||||
// then
|
||||
expect(sessionID).toBe("ses_ready_123")
|
||||
})
|
||||
|
||||
test("#given session appears later #when waiting #then it polls until resolved", async () => {
|
||||
// given
|
||||
const manager = createManager([
|
||||
{ status: "running" },
|
||||
{ status: "running" },
|
||||
{ sessionID: "ses_late_123", status: "running" },
|
||||
])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_late", {
|
||||
intervalMs: 1,
|
||||
timeoutMs: 20,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBe("ses_late_123")
|
||||
})
|
||||
|
||||
test("#given aborted signal #when waiting #then it returns undefined", async () => {
|
||||
// given
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const manager = createManager([{ status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_abort", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given task never resolves #when waiting past timeout #then it returns undefined", async () => {
|
||||
// given
|
||||
const manager = createManager([{ status: "running" }, { status: "running" }, { status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_timeout", {
|
||||
intervalMs: 1,
|
||||
timeoutMs: 3,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(["error", "cancelled", "interrupt"] satisfies BackgroundTaskStatus[])(
|
||||
"#given %s task state #when waiting #then it returns undefined",
|
||||
async (status: BackgroundTaskStatus) => {
|
||||
// given
|
||||
const manager = createManager([{ status }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, `bg_${status}`)
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user