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:
@@ -1,6 +1,6 @@
|
||||
# src/shared/ — 100+ Utility Files
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ describe("Agent Config Integration", () => {
|
||||
test("migrates old format agent keys to lowercase", () => {
|
||||
// given - config with old format keys
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-7" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-7" },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-7" },
|
||||
"Metis - Plan Consultant": { model: "anthropic/claude-sonnet-4-6" },
|
||||
"Momus - Plan Critic": { model: "anthropic/claude-sonnet-4-6" },
|
||||
}
|
||||
@@ -33,9 +33,9 @@ describe("Agent Config Integration", () => {
|
||||
expect(result.migrated).not.toHaveProperty("Momus - Plan Critic")
|
||||
|
||||
// then - values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
|
||||
// then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
@@ -44,7 +44,7 @@ describe("Agent Config Integration", () => {
|
||||
test("preserves already lowercase keys", () => {
|
||||
// given - config with lowercase keys
|
||||
const config = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-7" },
|
||||
oracle: { model: "openai/gpt-5.4" },
|
||||
librarian: { model: "opencode/big-pickle" },
|
||||
}
|
||||
@@ -62,9 +62,9 @@ describe("Agent Config Integration", () => {
|
||||
test("handles mixed case config", () => {
|
||||
// given - config with mixed old and new format
|
||||
const mixedConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-7" },
|
||||
oracle: { model: "openai/gpt-5.4" },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-7" },
|
||||
librarian: { model: "opencode/big-pickle" },
|
||||
}
|
||||
|
||||
@@ -173,8 +173,8 @@ describe("Agent Config Integration", () => {
|
||||
test("old config migrates and displays correctly", () => {
|
||||
// given - old format config
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-7", temperature: 0.1 },
|
||||
"Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
// when - config is migrated
|
||||
@@ -193,15 +193,15 @@ describe("Agent Config Integration", () => {
|
||||
expect(prometheusDisplay).toBe("Prometheus - Plan Builder")
|
||||
|
||||
// then - config values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-7", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
})
|
||||
|
||||
test("new config works without migration", () => {
|
||||
// given - new format config (already lowercase)
|
||||
const newConfig = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-7" },
|
||||
atlas: { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
// when - migration is applied (should be no-op)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { stripInvisibleAgentCharacters } from "./agent-display-names"
|
||||
|
||||
/**
|
||||
* Agent tool restrictions for session.prompt calls.
|
||||
* OpenCode SDK's session.prompt `tools` parameter expects boolean values.
|
||||
@@ -45,13 +47,15 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
return AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
// Custom/unknown agents get no restrictions (empty object), matching Claude Code's
|
||||
// trust model where project-registered agents retain full tool access including bash.
|
||||
const stripped = stripInvisibleAgentCharacters(agentName)
|
||||
return AGENT_RESTRICTIONS[stripped]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
|
||||
?? {}
|
||||
}
|
||||
|
||||
export function hasAgentToolRestrictions(agentName: string): boolean {
|
||||
const restrictions = AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
return restrictions !== undefined && Object.keys(restrictions).length > 0
|
||||
const restrictions = getAgentToolRestrictions(agentName)
|
||||
return Object.keys(restrictions).length > 0
|
||||
}
|
||||
|
||||
@@ -84,14 +84,14 @@ describe("applyAgentVariant", () => {
|
||||
|
||||
describe("resolveVariantForModel", () => {
|
||||
test("returns agent override variant when configured", () => {
|
||||
// given - use a model in sisyphus chain (claude-opus-4-6 has default variant "max")
|
||||
// given - use a model in sisyphus chain (claude-opus-4-7 has default variant "max")
|
||||
// to verify override takes precedence over fallback chain
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { variant: "high" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-7" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
@@ -103,7 +103,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for anthropic provider", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-7" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
@@ -151,7 +151,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns undefined for unknown agent", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-7" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "nonexistent-agent", model)
|
||||
@@ -203,7 +203,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for oracle agent with anthropic", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-7" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { stripInvisibleAgentCharacters } from "./agent-display-names"
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
|
||||
export function resolveAgentVariant(
|
||||
@@ -9,12 +10,13 @@ export function resolveAgentVariant(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const stripped = stripInvisibleAgentCharacters(agentName)
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { variant?: string; category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides
|
||||
? agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
? agentOverrides[stripped]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
|
||||
: undefined
|
||||
if (!agentOverride) {
|
||||
return undefined
|
||||
@@ -37,18 +39,19 @@ export function resolveVariantForModel(
|
||||
agentName: string,
|
||||
currentModel: { providerID: string; modelID: string },
|
||||
): string | undefined {
|
||||
const stripped = stripInvisibleAgentCharacters(agentName)
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { variant?: string; category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides
|
||||
? agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
? agentOverrides[stripped]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
|
||||
: undefined
|
||||
if (agentOverride?.variant) {
|
||||
return agentOverride.variant
|
||||
}
|
||||
|
||||
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
const agentRequirement = AGENT_MODEL_REQUIREMENTS[stripped]
|
||||
if (agentRequirement) {
|
||||
return findVariantInChain(agentRequirement.fallbackChain, currentModel)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("updateConnectedProvidersCache", () => {
|
||||
name: "Anthropic",
|
||||
env: [],
|
||||
models: {
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
"claude-opus-4-7": { id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
"claude-sonnet-4-6": { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("updateConnectedProvidersCache", () => {
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
],
|
||||
anthropic: [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -28,15 +28,15 @@ describe("resolveActualContextLimit", () => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
it("returns cached limit for Anthropic 4.6 models when 1M mode is disabled (GA support)", () => {
|
||||
it("returns cached limit for Anthropic 4.7 models when 1M mode is disabled (GA support)", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4-6", 1_000_000)
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4-7", 1_000_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-6", {
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-7", {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
@@ -107,15 +107,15 @@ describe("resolveActualContextLimit", () => {
|
||||
expect(actualLimit).toBe(200000)
|
||||
})
|
||||
|
||||
it("supports Anthropic 4.6 dot-version model IDs without explicit 1M mode", () => {
|
||||
it("supports Anthropic 4.7 dot-version model IDs without explicit 1M mode", () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4.6", 1_000_000)
|
||||
modelContextLimitsCache.set("anthropic/claude-opus-4.7", 1_000_000)
|
||||
|
||||
// when
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4.6", {
|
||||
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4.7", {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState):
|
||||
}
|
||||
|
||||
function supportsCachedAnthropicLimit(modelID: string): boolean {
|
||||
return /^claude-(opus|sonnet)-4(?:-|\.)6(?:-high)?$/.test(modelID)
|
||||
return /^claude-(opus|sonnet)-4(?:-|\.)(?:6|7)(?:-high)?$/.test(modelID)
|
||||
}
|
||||
|
||||
export function resolveActualContextLimit(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { EXCLUDED_DIRS } from "./excluded-dirs"
|
||||
import { EXCLUDED_DIRS as EXCLUDED_DIRS_FROM_BARREL } from "."
|
||||
|
||||
describe("EXCLUDED_DIRS", () => {
|
||||
test("contains the well-known junk directories we never want to recurse into", () => {
|
||||
// given
|
||||
const expected = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".sisyphus",
|
||||
".omx",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"out",
|
||||
".cache",
|
||||
".vscode-test",
|
||||
"target",
|
||||
".local-ignore",
|
||||
]
|
||||
|
||||
// when / then
|
||||
for (const name of expected) {
|
||||
expect(EXCLUDED_DIRS.has(name)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not contain commonly-wanted project directories", () => {
|
||||
// given
|
||||
const shouldBeAllowed = ["src", "lib", "tests", "test", "docs", ".github", ".cursor", ".claude", ".opencode"]
|
||||
|
||||
// when / then
|
||||
for (const name of shouldBeAllowed) {
|
||||
expect(EXCLUDED_DIRS.has(name)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("is frozen so consumers cannot mutate shared state", () => {
|
||||
// given / when / then
|
||||
expect(Object.isFrozen(EXCLUDED_DIRS)).toBe(true)
|
||||
})
|
||||
|
||||
test("is re-exported from the shared barrel", () => {
|
||||
// given / when / then
|
||||
expect(EXCLUDED_DIRS_FROM_BARREL).toBe(EXCLUDED_DIRS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
const EXCLUDED_DIR_NAMES = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".sisyphus",
|
||||
".omx",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"out",
|
||||
".cache",
|
||||
".vscode-test",
|
||||
"target",
|
||||
".local-ignore",
|
||||
] as const
|
||||
|
||||
export const EXCLUDED_DIRS: ReadonlySet<string> = Object.freeze(new Set<string>(EXCLUDED_DIR_NAMES))
|
||||
@@ -17,6 +17,7 @@ export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
export * from "./opencode-config-dir"
|
||||
export * from "./resolve-agent-definition-paths"
|
||||
export type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigDirOptions,
|
||||
@@ -56,6 +57,7 @@ export * from "./session-utils"
|
||||
export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
export * from "./opencode-server-auth"
|
||||
export * from "./opencode-provider-auth"
|
||||
export * from "./opencode-http-api"
|
||||
export * from "./port-utils"
|
||||
export * from "./git-worktree"
|
||||
@@ -75,3 +77,6 @@ export { SessionCategoryRegistry } from "./session-category-registry"
|
||||
export * from "./plugin-identity"
|
||||
export * from "./log-legacy-plugin-startup-warning"
|
||||
export * from "./task-system-enabled"
|
||||
export * from "./parse-tools-config"
|
||||
export { parseModelString } from "./model-string-parser"
|
||||
export { EXCLUDED_DIRS } from "./excluded-dirs"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
describe("detectPluginConfigFile memoization", () => {
|
||||
const testDir = join(__dirname, ".test-detect-plugin-memoization")
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("returns cached result on repeated calls for the same directory", async () => {
|
||||
// given
|
||||
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
|
||||
return String(filePath).endsWith("oh-my-openagent.jsonc")
|
||||
})
|
||||
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
|
||||
spyOn(fs, "readFileSync").mockImplementation(() => "")
|
||||
|
||||
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
|
||||
|
||||
// when
|
||||
const firstResult = parserModule.detectPluginConfigFile(testDir)
|
||||
const callsAfterFirstResult = existsSync.mock.calls.length
|
||||
const secondResult = parserModule.detectPluginConfigFile(testDir)
|
||||
|
||||
// then
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(existsSync.mock.calls.length).toBe(callsAfterFirstResult)
|
||||
expect(readdirSync).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("clears cached result when requested", async () => {
|
||||
// given
|
||||
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
|
||||
return String(filePath).endsWith("oh-my-openagent.jsonc")
|
||||
})
|
||||
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
|
||||
spyOn(fs, "readFileSync").mockImplementation(() => "")
|
||||
|
||||
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
|
||||
|
||||
parserModule.detectPluginConfigFile(testDir)
|
||||
parserModule.clearPluginConfigFileDetectionCache()
|
||||
const callsAfterClear = existsSync.mock.calls.length
|
||||
|
||||
// when
|
||||
parserModule.detectPluginConfigFile(testDir)
|
||||
|
||||
// then
|
||||
expect(existsSync.mock.calls.length).toBeGreaterThan(callsAfterClear)
|
||||
expect(readdirSync).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
@@ -330,6 +330,14 @@ describe("detectConfigFile", () => {
|
||||
describe("detectPluginConfigFile", () => {
|
||||
const testDir = join(__dirname, ".test-detect-plugin")
|
||||
|
||||
beforeEach(() => {
|
||||
clearPluginConfigFileDetectionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginConfigFileDetectionCache()
|
||||
})
|
||||
|
||||
test("prefers oh-my-openagent over oh-my-opencode when both jsonc files exist", () => {
|
||||
// given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
|
||||
+28
-10
@@ -9,6 +9,14 @@ export interface JsoncParseResult<T> {
|
||||
errors: Array<{ message: string; offset: number; length: number }>
|
||||
}
|
||||
|
||||
type DetectPluginConfigResult = {
|
||||
format: "json" | "jsonc" | "none"
|
||||
path: string
|
||||
legacyPath?: string
|
||||
}
|
||||
|
||||
const pluginConfigFileDetectionCache = new Map<string, DetectPluginConfigResult>()
|
||||
|
||||
function stripBom(content: string): string {
|
||||
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
|
||||
}
|
||||
@@ -75,24 +83,34 @@ export function detectConfigFile(basePath: string): {
|
||||
return { format: "none", path: jsonPath }
|
||||
}
|
||||
|
||||
export function detectPluginConfigFile(dir: string): {
|
||||
format: "json" | "jsonc" | "none"
|
||||
path: string
|
||||
legacyPath?: string
|
||||
} {
|
||||
export function clearPluginConfigFileDetectionCache(): void {
|
||||
pluginConfigFileDetectionCache.clear()
|
||||
}
|
||||
|
||||
export function detectPluginConfigFile(dir: string): DetectPluginConfigResult {
|
||||
const cachedResult = pluginConfigFileDetectionCache.get(dir)
|
||||
|
||||
if (cachedResult !== undefined) {
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME))
|
||||
const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME))
|
||||
|
||||
let detectionResult: DetectPluginConfigResult
|
||||
|
||||
if (canonicalResult.format !== "none") {
|
||||
return {
|
||||
detectionResult = {
|
||||
...canonicalResult,
|
||||
legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined,
|
||||
}
|
||||
} else if (legacyResult.format !== "none") {
|
||||
detectionResult = legacyResult
|
||||
} else {
|
||||
detectionResult = { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
|
||||
}
|
||||
|
||||
if (legacyResult.format !== "none") {
|
||||
return legacyResult
|
||||
}
|
||||
pluginConfigFileDetectionCache.set(dir, detectionResult)
|
||||
|
||||
return { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
|
||||
return detectionResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/// <reference path="../../bun-test.d.ts" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
|
||||
type LoadOpencodePluginsModule = {
|
||||
loadOpencodePlugins: (directory: string) => string[]
|
||||
clearOpencodePluginsCache?: () => void
|
||||
}
|
||||
|
||||
const existsSyncMock = mock((_path: string) => true)
|
||||
const readFileSyncMock = mock((_path: string, _encoding?: string) => `{
|
||||
"plugin": ["plugin-a", "plugin-b"]
|
||||
}`)
|
||||
|
||||
async function importFreshLoadOpencodePluginsModule(): Promise<LoadOpencodePluginsModule> {
|
||||
const modulePath = `${new URL("./load-opencode-plugins.ts", import.meta.url).pathname}?test=${Date.now()}-${Math.random()}`
|
||||
return import(modulePath)
|
||||
}
|
||||
|
||||
describe("loadOpencodePlugins", () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
existsSyncMock.mockImplementation((_path: string) => true)
|
||||
readFileSyncMock.mockReset()
|
||||
readFileSyncMock.mockImplementation((_path: string, _encoding?: string) => `{
|
||||
"plugin": ["plugin-a", "plugin-b"]
|
||||
}`)
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("#given the same directory is loaded twice", () => {
|
||||
describe("#when loading plugins repeatedly", () => {
|
||||
it("#then does not call readFileSync on the second load", async () => {
|
||||
// given
|
||||
const { loadOpencodePlugins } = await importFreshLoadOpencodePluginsModule()
|
||||
|
||||
// when
|
||||
const firstResult = loadOpencodePlugins("/some/fake/dir")
|
||||
const readCountAfterFirstLoad = readFileSyncMock.mock.calls.length
|
||||
const secondResult = loadOpencodePlugins("/some/fake/dir")
|
||||
const readCountAfterSecondLoad = readFileSyncMock.mock.calls.length
|
||||
|
||||
// then
|
||||
expect(firstResult).toEqual(["plugin-a", "plugin-b"])
|
||||
expect(secondResult).toEqual(["plugin-a", "plugin-b"])
|
||||
expect(readCountAfterFirstLoad).toBeGreaterThan(0)
|
||||
expect(readCountAfterSecondLoad - readCountAfterFirstLoad).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the plugin cache was cleared", () => {
|
||||
describe("#when loading the same directory again", () => {
|
||||
it("#then re-reads plugin config files from disk", async () => {
|
||||
// given
|
||||
const { loadOpencodePlugins, clearOpencodePluginsCache } = await importFreshLoadOpencodePluginsModule()
|
||||
|
||||
if (typeof clearOpencodePluginsCache !== "function") {
|
||||
throw new Error("clearOpencodePluginsCache export is missing")
|
||||
}
|
||||
|
||||
// when
|
||||
const firstResult = loadOpencodePlugins("/some/fake/dir")
|
||||
const readCountAfterFirstLoad = readFileSyncMock.mock.calls.length
|
||||
loadOpencodePlugins("/some/fake/dir")
|
||||
const readCountAfterSecondLoad = readFileSyncMock.mock.calls.length
|
||||
clearOpencodePluginsCache()
|
||||
const thirdResult = loadOpencodePlugins("/some/fake/dir")
|
||||
const readCountAfterThirdLoad = readFileSyncMock.mock.calls.length
|
||||
|
||||
// then
|
||||
expect(firstResult).toEqual(["plugin-a", "plugin-b"])
|
||||
expect(thirdResult).toEqual(["plugin-a", "plugin-b"])
|
||||
expect(readCountAfterSecondLoad - readCountAfterFirstLoad).toBe(0)
|
||||
expect(readCountAfterThirdLoad - readCountAfterSecondLoad).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ interface OpencodeConfig {
|
||||
plugin?: (string | [string, ...unknown[]])[]
|
||||
}
|
||||
|
||||
const opencodePluginsCache = new Map<string, string[]>()
|
||||
|
||||
function getWindowsAppdataDir(): string | null {
|
||||
return process.env.APPDATA || null
|
||||
}
|
||||
@@ -33,6 +35,11 @@ function getConfigPaths(directory: string): string[] {
|
||||
}
|
||||
|
||||
export function loadOpencodePlugins(directory: string): string[] {
|
||||
const cachedPluginEntries = opencodePluginsCache.get(directory)
|
||||
if (cachedPluginEntries) {
|
||||
return cachedPluginEntries
|
||||
}
|
||||
|
||||
const pluginEntries: string[] = []
|
||||
const seenPluginEntries = new Set<string>()
|
||||
|
||||
@@ -56,5 +63,10 @@ export function loadOpencodePlugins(directory: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
opencodePluginsCache.set(directory, pluginEntries)
|
||||
return pluginEntries
|
||||
}
|
||||
|
||||
export function clearOpencodePluginsCache(): void {
|
||||
opencodePluginsCache.clear()
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("logLegacyPluginStartupWarning", () => {
|
||||
//#then
|
||||
expect(mockLog).toHaveBeenCalledTimes(1)
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config",
|
||||
"[legacy-migration] Legacy plugin entry detected in OpenCode config",
|
||||
{
|
||||
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
|
||||
suggestedEntries: ["oh-my-openagent", "oh-my-openagent@3.13.1"],
|
||||
|
||||
@@ -22,7 +22,7 @@ export function logLegacyPluginStartupWarning(deps: LogLegacyPluginStartupWarnin
|
||||
|
||||
const suggestedEntries = result.legacyEntries.map(toCanonicalEntry)
|
||||
|
||||
logFn("[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config", {
|
||||
logFn("[legacy-migration] Legacy plugin entry detected in OpenCode config", {
|
||||
legacyEntries: result.legacyEntries,
|
||||
suggestedEntries,
|
||||
hasCanonicalEntry: result.hasCanonicalEntry,
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("mergeCategories", () => {
|
||||
it("user overrides merge with defaults", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"ultrabrain": { model: "anthropic/claude-opus-4-6" },
|
||||
"ultrabrain": { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
//#when
|
||||
@@ -79,6 +79,6 @@ describe("mergeCategories", () => {
|
||||
|
||||
//#then
|
||||
expect(result["ultrabrain"]).toBeDefined()
|
||||
expect(result["ultrabrain"].model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result["ultrabrain"].model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to lowercase", () => {
|
||||
// given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-6" },
|
||||
omo: { model: "anthropic/claude-opus-4-7" },
|
||||
OmO: { temperature: 0.5 },
|
||||
"OmO-Plan": { prompt: "custom prompt" },
|
||||
}
|
||||
@@ -88,7 +88,7 @@ describe("migrateAgentNames", () => {
|
||||
test("migrates orchestrator-sisyphus to atlas", () => {
|
||||
// given: Config with legacy orchestrator-sisyphus agent name
|
||||
const agents = {
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-6" },
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
// when: Migrate agent names
|
||||
@@ -96,14 +96,14 @@ describe("migrateAgentNames", () => {
|
||||
|
||||
// then: orchestrator-sisyphus should be migrated to atlas
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates lowercase atlas to atlas", () => {
|
||||
// given: Config with lowercase atlas agent name
|
||||
const agents = {
|
||||
atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
atlas: { model: "anthropic/claude-opus-4-7" },
|
||||
}
|
||||
|
||||
// when: Migrate agent names
|
||||
@@ -111,7 +111,7 @@ describe("migrateAgentNames", () => {
|
||||
|
||||
// then: lowercase atlas should remain atlas (no change needed)
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-7" })
|
||||
})
|
||||
|
||||
test("migrates Sisyphus variants to lowercase", () => {
|
||||
@@ -524,7 +524,7 @@ describe("migrateConfigFile", () => {
|
||||
// then: Model version should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const categories = rawConfig.categories as Record<string, Record<string, unknown>>
|
||||
expect(categories["my-category"].model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(categories["my-category"].model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("does not set needsWrite when no model versions need migration", () => {
|
||||
@@ -534,7 +534,7 @@ describe("migrateConfigFile", () => {
|
||||
sisyphus: { model: "openai/gpt-5.4-codex" },
|
||||
},
|
||||
categories: {
|
||||
"my-category": { model: "anthropic/claude-opus-4-6" },
|
||||
"my-category": { model: "anthropic/claude-opus-4-7" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -572,10 +572,10 @@ describe("MODEL_VERSION_MAP", () => {
|
||||
expect(MODEL_VERSION_MAP["openai/gpt-5.4-codex"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps anthropic/claude-opus-4-5 to anthropic/claude-opus-4-6", () => {
|
||||
test("maps anthropic/claude-opus-4-5 to anthropic/claude-opus-4-7", () => {
|
||||
// given/when: Check MODEL_VERSION_MAP
|
||||
// then: Should contain correct mapping
|
||||
expect(MODEL_VERSION_MAP["anthropic/claude-opus-4-5"]).toBe("anthropic/claude-opus-4-6")
|
||||
expect(MODEL_VERSION_MAP["anthropic/claude-opus-4-5"]).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("maps openai/gpt-5.3-codex to openai/gpt-5.4 for deep category migration", () => {
|
||||
@@ -614,7 +614,7 @@ describe("migrateModelVersions", () => {
|
||||
// then: Model should be updated
|
||||
expect(changed).toBe(true)
|
||||
const prometheus = migrated["prometheus"] as Record<string, unknown>
|
||||
expect(prometheus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(prometheus.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("leaves unknown model strings untouched", () => {
|
||||
@@ -674,7 +674,7 @@ describe("migrateModelVersions", () => {
|
||||
// then: Only mapped models should be updated
|
||||
expect(changed).toBe(true)
|
||||
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
||||
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-7")
|
||||
expect((migrated["oracle"] as Record<string, unknown>).model).toBe("openai/gpt-5.4")
|
||||
})
|
||||
|
||||
@@ -736,9 +736,9 @@ describe("migrateModelVersions", () => {
|
||||
|
||||
// then: Only prometheus should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"])
|
||||
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7"])
|
||||
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
||||
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("backward compatible without appliedMigrations param", () => {
|
||||
@@ -820,12 +820,12 @@ describe("migrateConfigFile _migrations tracking", () => {
|
||||
// (legacy + new) is written to the sidecar file exactly once.
|
||||
expect(result).toBe(true)
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe("anthropic/claude-opus-4-7")
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8"))
|
||||
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
||||
"model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
]))
|
||||
|
||||
// cleanup
|
||||
@@ -890,7 +890,7 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
{ model: "google/gemini-3-flash" },
|
||||
{ model: "openai/gpt-5.4" },
|
||||
{ model: "anthropic/claude-haiku-4-5" },
|
||||
{ model: "anthropic/claude-opus-4-6" },
|
||||
{ model: "anthropic/claude-opus-4-7" },
|
||||
{ model: "anthropic/claude-sonnet-4-6" },
|
||||
]
|
||||
|
||||
@@ -970,7 +970,7 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
// given: Config with custom model override
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
}
|
||||
|
||||
// when: Check if config should be deleted
|
||||
@@ -1245,9 +1245,9 @@ describe("migrateModelVersions with applied migrations", () => {
|
||||
|
||||
// then: Skip sisyphus (already applied), apply oracle
|
||||
expect(changed).toBe(true)
|
||||
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"])
|
||||
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7"])
|
||||
expect((migrated.sisyphus as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
||||
expect((migrated.oracle as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((migrated.oracle as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("backward compatible: no appliedMigrations param", () => {
|
||||
@@ -1334,12 +1334,12 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
expect(needsWrite).toBe(true)
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(sidecar.appliedMigrations).toEqual([
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1408,7 +1408,7 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
JSON.stringify({
|
||||
appliedMigrations: [
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -1416,7 +1416,7 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
_migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"],
|
||||
_migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7"],
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
|
||||
@@ -1430,7 +1430,7 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(sidecar.appliedMigrations).toEqual([
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
])
|
||||
})
|
||||
@@ -1460,13 +1460,13 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
// codex was reverted, must stay
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).codex.model).toBe("openai/gpt-5.3-codex")
|
||||
// claude migrates
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).claude.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).claude.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
]))
|
||||
})
|
||||
|
||||
@@ -1494,7 +1494,7 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () =>
|
||||
expect(Array.isArray(migrations)).toBe(true)
|
||||
expect(migrations).toContain("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")
|
||||
expect(migrations.length).toBeGreaterThanOrEqual(1)
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-7")
|
||||
|
||||
// Sidecar should not exist because write failed
|
||||
expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false)
|
||||
|
||||
@@ -17,6 +17,7 @@ export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
||||
"openai/gpt-5.4": "ultrabrain",
|
||||
"anthropic/claude-haiku-4-5": "quick",
|
||||
"anthropic/claude-opus-4-6": "unspecified-high",
|
||||
"anthropic/claude-opus-4-7": "unspecified-high",
|
||||
"anthropic/claude-sonnet-4-6": "unspecified-low",
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { migrateConfigFile } from "./config-migration"
|
||||
import { getSidecarPath } from "./migrations-sidecar"
|
||||
|
||||
const createdDirectories: string[] = []
|
||||
const MIGRATION_KEY = "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"
|
||||
const MIGRATION_KEY = "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7"
|
||||
|
||||
function createWorkdir(): string {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "omo-config-migration-"))
|
||||
@@ -46,13 +46,13 @@ describe("migrateConfigFile sidecar write ordering", () => {
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe(
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
)
|
||||
|
||||
const persistedConfig = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>
|
||||
expect(persistedConfig._migrations).toBeUndefined()
|
||||
expect((persistedConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe(
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
)
|
||||
|
||||
const sidecar = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) as {
|
||||
@@ -87,7 +87,7 @@ describe("migrateConfigFile sidecar write ordering", () => {
|
||||
expect(retriedNeedsWrite).toBe(true)
|
||||
expect(retriedConfig._migrations).toBeUndefined()
|
||||
expect((retriedConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe(
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
)
|
||||
expect(existsSync(getSidecarPath(configPath))).toBe(true)
|
||||
})
|
||||
@@ -108,14 +108,63 @@ describe("migrateConfigFile sidecar write ordering", () => {
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig._migrations).toEqual([MIGRATION_KEY])
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe(
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
)
|
||||
|
||||
const persistedConfig = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>
|
||||
expect(persistedConfig._migrations).toEqual([MIGRATION_KEY])
|
||||
expect((persistedConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe(
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
)
|
||||
expect(statSync(getSidecarPath(configPath)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateConfigFile backup skipping", () => {
|
||||
test("skips backup when file content is identical after migration", () => {
|
||||
// given - config with legacy key that migrates to same on-disk content
|
||||
const workdir = createWorkdir()
|
||||
const configPath = join(workdir, "oh-my-opencode.json")
|
||||
const migratedContent = {
|
||||
disabled_hooks: ["comment-checker"],
|
||||
}
|
||||
|
||||
// Write the already-migrated content to disk
|
||||
writeFileSync(configPath, JSON.stringify(migratedContent, null, 2) + "\n")
|
||||
|
||||
// rawConfig still has the legacy hook that will be removed
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
disabled_hooks: ["gpt-permission-continuation", "comment-checker"],
|
||||
}
|
||||
|
||||
// when
|
||||
migrateConfigFile(configPath, rawConfig)
|
||||
|
||||
// then - no backup file should be created since file content is unchanged
|
||||
const files = require("fs").readdirSync(workdir) as string[]
|
||||
const backupFiles = files.filter((f: string) => f.includes(".bak."))
|
||||
expect(backupFiles.length).toBe(0)
|
||||
})
|
||||
|
||||
test("creates backup when file content actually changes", () => {
|
||||
// given - config with model that needs migration
|
||||
const workdir = createWorkdir()
|
||||
const configPath = join(workdir, "oh-my-opencode.json")
|
||||
const rawConfig = {
|
||||
agents: {
|
||||
prometheus: { model: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n")
|
||||
|
||||
// when
|
||||
const needsWrite = migrateConfigFile(configPath, rawConfig as Record<string, unknown>)
|
||||
|
||||
// then - backup should be created since content changed
|
||||
expect(needsWrite).toBe(true)
|
||||
const files = require("fs").readdirSync(workdir) as string[]
|
||||
const backupFiles = files.filter((f: string) => f.includes(".bak."))
|
||||
expect(backupFiles.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,20 +143,36 @@ export function migrateConfigFile(
|
||||
}
|
||||
|
||||
if (needsWrite) {
|
||||
let finalConfig = JSON.parse(JSON.stringify(copy)) as Record<string, unknown>
|
||||
const newContent = JSON.stringify(finalConfig, null, 2) + "\n"
|
||||
|
||||
// Compare with existing file content to skip backup when unchanged.
|
||||
// The config may still need an in-memory migration even if the file
|
||||
// content is identical (e.g. removing a deleted hook from disabled_hooks
|
||||
// results in content that was already written by a prior migration).
|
||||
let existingContent: string | undefined
|
||||
try {
|
||||
existingContent = fs.readFileSync(configPath, "utf-8")
|
||||
} catch {
|
||||
// File may not exist yet
|
||||
}
|
||||
const contentChanged = existingContent !== newContent
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const backupPath = `${configPath}.bak.${timestamp}`
|
||||
let backupSucceeded = false
|
||||
try {
|
||||
fs.copyFileSync(configPath, backupPath)
|
||||
backupSucceeded = true
|
||||
} catch {
|
||||
backupSucceeded = false
|
||||
if (contentChanged) {
|
||||
try {
|
||||
fs.copyFileSync(configPath, backupPath)
|
||||
backupSucceeded = true
|
||||
} catch {
|
||||
backupSucceeded = false
|
||||
}
|
||||
}
|
||||
|
||||
let writeSucceeded = false
|
||||
let finalConfig = JSON.parse(JSON.stringify(copy)) as Record<string, unknown>
|
||||
try {
|
||||
writeFileAtomically(configPath, JSON.stringify(finalConfig, null, 2) + "\n")
|
||||
writeFileAtomically(configPath, newContent)
|
||||
writeSucceeded = true
|
||||
} catch (err) {
|
||||
log(`Failed to write migrated config to ${configPath}:`, err)
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("migrations sidecar", () => {
|
||||
JSON.stringify({
|
||||
appliedMigrations: [
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -51,7 +51,7 @@ describe("migrations sidecar", () => {
|
||||
|
||||
expect(applied.size).toBe(2)
|
||||
expect(applied.has("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")).toBe(true)
|
||||
expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns an empty set on malformed JSON instead of throwing", () => {
|
||||
@@ -134,7 +134,7 @@ describe("migrations sidecar", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.jsonc")
|
||||
const original = new Set([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7",
|
||||
])
|
||||
|
||||
writeAppliedMigrations(configPath, original)
|
||||
|
||||
@@ -22,7 +22,7 @@ import { writeFileAtomically } from "../write-file-atomically"
|
||||
* {
|
||||
* "appliedMigrations": [
|
||||
* "model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
* "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"
|
||||
* "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-7"
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* Keys are full "provider/model" strings. Only openai and anthropic entries needed.
|
||||
*/
|
||||
export const MODEL_VERSION_MAP: Record<string, string> = {
|
||||
"anthropic/claude-opus-4-5": "anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-5": "anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-opus-4-6": "anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-4-5": "anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-5.3-codex": "openai/gpt-5.4",
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("fetchAvailableModels", () => {
|
||||
it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
google: { id: "google", models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("fetchAvailableModels", () => {
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("openai/gpt-5.4")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("fetchAvailableModels", () => {
|
||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
})
|
||||
|
||||
const result1 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
@@ -192,7 +192,7 @@ describe("fuzzyMatchModel", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt-5.4", available)
|
||||
expect(result).toBe("openai/gpt-5.4")
|
||||
@@ -239,25 +239,25 @@ describe("fuzzyMatchModel", () => {
|
||||
// given available models with claude variants
|
||||
// when searching for claude-opus
|
||||
// then return matching claude-opus model
|
||||
it("should match claude-opus to claude-opus-4-6", () => {
|
||||
it("should match claude-opus to claude-opus-4-7", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude-opus", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
// given github-copilot serves claude versions with dot notation
|
||||
// when fallback chain uses hyphen notation in requested model
|
||||
// then normalize both forms and match github-copilot model
|
||||
it("should match github-copilot claude-opus-4-6 to claude-opus-4.6", () => {
|
||||
it("should match github-copilot claude-opus-4-7 to claude-opus-4.7", () => {
|
||||
const available = new Set([
|
||||
"github-copilot/claude-opus-4.6",
|
||||
"github-copilot/claude-opus-4.7",
|
||||
"opencode/big-pickle",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude-opus-4-6", available, ["github-copilot"])
|
||||
expect(result).toBe("github-copilot/claude-opus-4.6")
|
||||
const result = fuzzyMatchModel("claude-opus-4-7", available, ["github-copilot"])
|
||||
expect(result).toBe("github-copilot/claude-opus-4.7")
|
||||
})
|
||||
|
||||
// given claude models can evolve to newer version numbers
|
||||
@@ -275,7 +275,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should filter by provider when providers array is given", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"google/gemini-3",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt", available, ["openai"])
|
||||
@@ -288,7 +288,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should return null when provider filter excludes all matches", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude", available, ["openai"])
|
||||
expect(result).toBeNull()
|
||||
@@ -300,7 +300,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should return null when no match found", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
])
|
||||
const result = fuzzyMatchModel("gemini", available)
|
||||
expect(result).toBeNull()
|
||||
@@ -312,7 +312,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should match case-insensitively", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
])
|
||||
const result = fuzzyMatchModel("GPT-5.4", available)
|
||||
expect(result).toBe("openai/gpt-5.4")
|
||||
@@ -323,11 +323,11 @@ describe("fuzzyMatchModel", () => {
|
||||
// then return exact match first
|
||||
it("should prioritize exact match over longer variants", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-6-extended",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-opus-4-7-extended",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude-opus-4-6", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
const result = fuzzyMatchModel("claude-opus-4-7", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
// given available models with similar model IDs (e.g., glm-5 and big-pickle)
|
||||
@@ -372,7 +372,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should search all specified providers", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"google/gemini-3",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt", available, ["openai", "google"])
|
||||
@@ -520,7 +520,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should filter models by connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
@@ -529,7 +529,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.4")).toBe(false)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
@@ -540,7 +540,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should filter models by multiple connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
@@ -549,7 +549,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.4")).toBe(false)
|
||||
})
|
||||
@@ -560,7 +560,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return empty set when connectedProviders is empty", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -576,7 +576,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return empty set when connectedProviders not specified", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels()
|
||||
@@ -605,7 +605,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return models from providers that exist in both cache and connected list", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -613,7 +613,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
})
|
||||
|
||||
// given filtered fetch
|
||||
@@ -622,7 +622,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should not cache filtered results", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.4": { id: "gpt-5.4" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
anthropic: { models: { "claude-opus-4-7": { id: "claude-opus-4-7" } } },
|
||||
})
|
||||
|
||||
// First call with anthropic
|
||||
@@ -706,13 +706,13 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["big-pickle", "gpt-5-nano"],
|
||||
anthropic: ["claude-opus-4-6"]
|
||||
anthropic: ["claude-opus-4-7"]
|
||||
},
|
||||
connected: ["opencode", "anthropic"]
|
||||
})
|
||||
writeModelsCache({
|
||||
opencode: { models: { "big-pickle": {}, "gpt-5-nano": {}, "gpt-5.4": {} } },
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } }
|
||||
anthropic: { models: { "claude-opus-4-7": {}, "claude-sonnet-4-6": {} } }
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -722,7 +722,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("opencode/big-pickle")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5-nano")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5.4")).toBe(false)
|
||||
expect(result.has("anthropic/claude-sonnet-4-6")).toBe(false)
|
||||
})
|
||||
@@ -773,7 +773,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["big-pickle"],
|
||||
anthropic: ["claude-opus-4-6"],
|
||||
anthropic: ["claude-opus-4-7"],
|
||||
google: ["gemini-3.1-pro"]
|
||||
},
|
||||
connected: ["opencode", "anthropic", "google"]
|
||||
@@ -785,7 +785,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("opencode/big-pickle")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(false)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -812,7 +812,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
it("should handle mixed string[] and object[] formats across providers", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6"],
|
||||
anthropic: ["claude-opus-4-7", "claude-sonnet-4-6"],
|
||||
ollama: [
|
||||
{ id: "ministral-3:14b-32k-agent", provider: "ollama" },
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama" }
|
||||
@@ -826,7 +826,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
})
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-6")).toBe(true)
|
||||
expect(result.has("ollama/ministral-3:14b-32k-agent")).toBe(true)
|
||||
expect(result.has("ollama/qwen3-coder:32k-agent")).toBe(true)
|
||||
@@ -859,7 +859,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
describe("isModelAvailable", () => {
|
||||
it("returns true when model exists via fuzzy match", () => {
|
||||
// given
|
||||
const available = new Set(["openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
const available = new Set(["openai/gpt-5.3-codex", "anthropic/claude-opus-4-7"])
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
@@ -870,7 +870,7 @@ describe("isModelAvailable", () => {
|
||||
|
||||
it("returns false when model not found", () => {
|
||||
// given
|
||||
const available = new Set(["anthropic/claude-opus-4-6"])
|
||||
const available = new Set(["anthropic/claude-opus-4-7"])
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
@@ -924,7 +924,7 @@ describe("fallback model availability", () => {
|
||||
|
||||
it("returns null for completely unknown model", () => {
|
||||
// given
|
||||
const available = new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-6"])
|
||||
const available = new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-7"])
|
||||
|
||||
// when
|
||||
const result = fuzzyMatchModel("non-existent-model-family", available)
|
||||
@@ -936,7 +936,7 @@ describe("fallback model availability", () => {
|
||||
it("returns true when models do not match but provider is connected", () => {
|
||||
// given
|
||||
const fallbackChain = [{ providers: ["openai"], model: "gpt-5.4" }]
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-6"])
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-7"])
|
||||
writeConnectedProvidersCache(["openai"])
|
||||
|
||||
// when
|
||||
@@ -950,10 +950,10 @@ describe("fallback model availability", () => {
|
||||
// given
|
||||
const fallbackChain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
]
|
||||
const availableModels = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"openai/gpt-5.4-preview",
|
||||
])
|
||||
|
||||
@@ -968,7 +968,7 @@ describe("fallback model availability", () => {
|
||||
// given
|
||||
const fallbackChain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
]
|
||||
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||
* If providers array is given, only models starting with "provider/" are considered.
|
||||
*
|
||||
* @example
|
||||
* const available = new Set(["openai/gpt-5.4", "openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
* const available = new Set(["openai/gpt-5.4", "openai/gpt-5.3-codex", "anthropic/claude-opus-4-7"])
|
||||
* fuzzyMatchModel("gpt-5.4", available) // → "openai/gpt-5.4"
|
||||
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,8 @@ describe("getModelCapabilities", () => {
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
sourceUrl: "https://models.dev/api.json",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
"claude-opus-4-7": {
|
||||
id: "claude-opus-4-7",
|
||||
family: "claude-opus",
|
||||
reasoning: true,
|
||||
temperature: true,
|
||||
@@ -66,7 +66,7 @@ describe("getModelCapabilities", () => {
|
||||
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
|
||||
const result = getModelCapabilities({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
runtimeModel: {
|
||||
variants: {
|
||||
low: {},
|
||||
@@ -78,7 +78,7 @@ describe("getModelCapabilities", () => {
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
canonicalModelID: "claude-opus-4-6",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
family: "claude-opus",
|
||||
variants: ["low", "medium", "high"],
|
||||
supportsThinking: true,
|
||||
@@ -173,12 +173,12 @@ describe("getModelCapabilities", () => {
|
||||
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
|
||||
const result = getModelCapabilities({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
modelID: "claude-opus-4-7-thinking",
|
||||
bundledSnapshot,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
canonicalModelID: "claude-opus-4-6",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
family: "claude-opus",
|
||||
supportsThinking: true,
|
||||
supportsTemperature: true,
|
||||
@@ -247,13 +247,13 @@ describe("getModelCapabilities", () => {
|
||||
test("canonicalizes provider-prefixed Claude thinking aliases to bare snapshot IDs", () => {
|
||||
const result = getModelCapabilities({
|
||||
providerID: "anthropic",
|
||||
modelID: "anthropic/claude-opus-4-6-thinking",
|
||||
modelID: "anthropic/claude-opus-4-7-thinking",
|
||||
bundledSnapshot,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
requestedModelID: "anthropic/claude-opus-4-6-thinking",
|
||||
canonicalModelID: "claude-opus-4-6",
|
||||
requestedModelID: "anthropic/claude-opus-4-7-thinking",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
family: "claude-opus",
|
||||
supportsThinking: true,
|
||||
supportsTemperature: true,
|
||||
|
||||
@@ -67,22 +67,22 @@ describe("model-capability-aliases", () => {
|
||||
})
|
||||
|
||||
test("normalizes provider-prefixed Claude thinking aliases through a pattern rule", () => {
|
||||
const result = resolveModelIDAlias("anthropic/claude-opus-4-6-thinking")
|
||||
const result = resolveModelIDAlias("anthropic/claude-opus-4-7-thinking")
|
||||
|
||||
expect(result).toEqual({
|
||||
requestedModelID: "anthropic/claude-opus-4-6-thinking",
|
||||
canonicalModelID: "claude-opus-4-6",
|
||||
requestedModelID: "anthropic/claude-opus-4-7-thinking",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
source: "pattern-alias",
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not pattern-match nearby canonical Claude IDs incorrectly", () => {
|
||||
const result = resolveModelIDAlias("claude-opus-4-6-think")
|
||||
const result = resolveModelIDAlias("claude-opus-4-7-think")
|
||||
|
||||
expect(result).toEqual({
|
||||
requestedModelID: "claude-opus-4-6-think",
|
||||
canonicalModelID: "claude-opus-4-6-think",
|
||||
requestedModelID: "claude-opus-4-7-think",
|
||||
canonicalModelID: "claude-opus-4-7-think",
|
||||
source: "canonical",
|
||||
})
|
||||
})
|
||||
@@ -98,11 +98,11 @@ describe("model-capability-aliases", () => {
|
||||
})
|
||||
|
||||
test("normalizes legacy Claude thinking aliases through a pattern rule", () => {
|
||||
const result = resolveModelIDAlias("claude-opus-4-6-thinking")
|
||||
const result = resolveModelIDAlias("claude-opus-4-7-thinking")
|
||||
|
||||
expect(result).toEqual({
|
||||
requestedModelID: "claude-opus-4-6-thinking",
|
||||
canonicalModelID: "claude-opus-4-6",
|
||||
requestedModelID: "claude-opus-4-7-thinking",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
source: "pattern-alias",
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
})
|
||||
|
||||
@@ -41,9 +41,9 @@ const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
|
||||
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
|
||||
{
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
description: "Normalizes the legacy Claude Opus 4.6 thinking suffix to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^claude-opus-4-6-thinking$/.test(normalizedModelID),
|
||||
canonicalize: () => "claude-opus-4-6",
|
||||
description: "Normalizes legacy Claude Opus thinking suffixes (4-6, 4-7) to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^claude-opus-4-(?:6|7)-thinking$/.test(normalizedModelID),
|
||||
canonicalize: () => "claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
ruleID: "gemini-3.1-pro-tier-alias",
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("model-capability-guardrails", () => {
|
||||
|
||||
expect(modelIDs).toEqual([...modelIDs].sort())
|
||||
expect(new Set(modelIDs).size).toBe(modelIDs.length)
|
||||
expect(modelIDs).toContain("claude-opus-4-6")
|
||||
expect(modelIDs).toContain("claude-opus-4-7")
|
||||
expect(modelIDs).toContain("gpt-5.4")
|
||||
expect(modelIDs).toContain("kimi-k2.5")
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("model-error-classifier", () => {
|
||||
//#given
|
||||
const error = {
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
}
|
||||
|
||||
//#when
|
||||
|
||||
@@ -9,8 +9,8 @@ describe("normalizeModelFormat", () => {
|
||||
})
|
||||
|
||||
it("handles provider with multiple slashes", () => {
|
||||
const result = normalizeModelFormat("anthropic/claude-opus-4-6/max")
|
||||
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6/max" })
|
||||
const result = normalizeModelFormat("anthropic/claude-opus-4-7/max")
|
||||
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7/max" })
|
||||
})
|
||||
|
||||
it("returns undefined for malformed string without separator", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("sisyphus has claude-opus-4-6 as primary with k2p5, kimi-k2.5, gpt-5.4 medium fallbacks", () => {
|
||||
test("sisyphus has claude-opus-4-7 as primary with k2p5, kimi-k2.5, gpt-5.4 medium fallbacks", () => {
|
||||
// #given - sisyphus agent requirement
|
||||
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
@@ -35,12 +35,12 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(sisyphus.requiresAnyModel).toBe(true)
|
||||
|
||||
const primary = sisyphus.fallbackChain[0]
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
|
||||
expect(primary.model).toBe("claude-opus-4-7")
|
||||
expect(primary.variant).toBe("max")
|
||||
|
||||
const second = sisyphus.fallbackChain[1]
|
||||
expect(second.providers).toEqual(["opencode-go"])
|
||||
expect(second.providers).toEqual(["opencode-go", "vercel"])
|
||||
expect(second.model).toBe("kimi-k2.5")
|
||||
|
||||
const third = sisyphus.fallbackChain[2]
|
||||
@@ -132,56 +132,56 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(multimodalLooker.fallbackChain).toHaveLength(4)
|
||||
|
||||
const primary = multimodalLooker.fallbackChain[0]
|
||||
expect(primary.providers).toEqual(["openai", "opencode"])
|
||||
expect(primary.providers).toEqual(["openai", "opencode", "vercel"])
|
||||
expect(primary.model).toBe("gpt-5.4")
|
||||
expect(primary.variant).toBe("medium")
|
||||
|
||||
const secondary = multimodalLooker.fallbackChain[1]
|
||||
expect(secondary.providers).toEqual(["opencode-go"])
|
||||
expect(secondary.providers).toEqual(["opencode-go", "vercel"])
|
||||
expect(secondary.model).toBe("kimi-k2.5")
|
||||
|
||||
const tertiary = multimodalLooker.fallbackChain[2]
|
||||
expect(tertiary.model).toBe("glm-4.6v")
|
||||
|
||||
const last = multimodalLooker.fallbackChain[3]
|
||||
expect(last.providers).toEqual(["openai", "github-copilot", "opencode"])
|
||||
expect(last.providers).toEqual(["openai", "github-copilot", "opencode", "vercel"])
|
||||
expect(last.model).toBe("gpt-5-nano")
|
||||
})
|
||||
|
||||
test("prometheus has claude-opus-4-6 as primary", () => {
|
||||
test("prometheus has claude-opus-4-7 as primary", () => {
|
||||
// #given - prometheus agent requirement
|
||||
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
|
||||
|
||||
// #when - accessing Prometheus requirement
|
||||
// #then - claude-opus-4-6 is first
|
||||
// #then - claude-opus-4-7 is first
|
||||
expect(prometheus).toBeDefined()
|
||||
expect(prometheus.fallbackChain).toBeArray()
|
||||
expect(prometheus.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = prometheus.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.model).toBe("claude-opus-4-7")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
|
||||
expect(primary.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("metis has claude-opus-4-6 as primary", () => {
|
||||
test("metis has claude-opus-4-7 as primary", () => {
|
||||
// #given - metis agent requirement
|
||||
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
|
||||
|
||||
// #when - accessing Metis requirement
|
||||
// #then - claude-opus-4-6 is first
|
||||
// #then - claude-opus-4-7 is first
|
||||
expect(metis).toBeDefined()
|
||||
expect(metis.fallbackChain).toBeArray()
|
||||
expect(metis.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = metis.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.model).toBe("claude-opus-4-7")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
|
||||
expect(primary.variant).toBe("max")
|
||||
|
||||
const openAiFallback = metis.fallbackChain.find((entry) => entry.providers.includes("openai"))
|
||||
expect(openAiFallback).toEqual({
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
})
|
||||
@@ -223,7 +223,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
const tertiary = atlas.fallbackChain[2]
|
||||
expect(tertiary).toEqual({
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
})
|
||||
@@ -245,7 +245,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
// then
|
||||
expect(openAiFallback).toEqual({
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
})
|
||||
@@ -261,7 +261,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
// #when - accessing hephaestus requirement
|
||||
// #then - requiresProvider includes openai, github-copilot, venice, and opencode
|
||||
expect(hephaestus).toBeDefined()
|
||||
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "venice", "opencode"])
|
||||
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "venice", "opencode", "vercel"])
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(second.model).toBe("glm-5")
|
||||
|
||||
const third = visualEngineering.fallbackChain[2]
|
||||
expect(third.model).toBe("claude-opus-4-6")
|
||||
expect(third.model).toBe("claude-opus-4-7")
|
||||
expect(third.variant).toBe("max")
|
||||
|
||||
const fourth = visualEngineering.fallbackChain[3]
|
||||
@@ -402,25 +402,25 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
})
|
||||
|
||||
test("unspecified-high has claude-opus-4-6 as primary and gpt-5.4 as secondary", () => {
|
||||
test("unspecified-high has claude-opus-4-7 as primary and gpt-5.4 as secondary", () => {
|
||||
// #given - unspecified-high category requirement
|
||||
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
|
||||
|
||||
// #when - accessing unspecified-high requirement
|
||||
// #then - claude-opus-4-6 is first and gpt-5.4 is second
|
||||
// #then - claude-opus-4-7 is first and gpt-5.4 is second
|
||||
expect(unspecifiedHigh).toBeDefined()
|
||||
expect(unspecifiedHigh.fallbackChain).toBeArray()
|
||||
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = unspecifiedHigh.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.model).toBe("claude-opus-4-7")
|
||||
expect(primary.variant).toBe("max")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
|
||||
|
||||
const secondary = unspecifiedHigh.fallbackChain[1]
|
||||
expect(secondary.model).toBe("gpt-5.4")
|
||||
expect(secondary.variant).toBe("high")
|
||||
expect(secondary.providers).toEqual(["openai", "github-copilot", "opencode"])
|
||||
expect(secondary.providers).toEqual(["openai", "github-copilot", "opencode", "vercel"])
|
||||
})
|
||||
|
||||
test("artistry has valid fallbackChain with gemini-3.1-pro as primary", () => {
|
||||
@@ -505,14 +505,14 @@ describe("FallbackEntry type", () => {
|
||||
// given - a valid FallbackEntry object
|
||||
const entry: FallbackEntry = {
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
model: "claude-opus-4-7",
|
||||
variant: "high",
|
||||
}
|
||||
|
||||
// when - accessing properties
|
||||
// then - all properties are accessible
|
||||
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(entry.model).toBe("claude-opus-4-6")
|
||||
expect(entry.model).toBe("claude-opus-4-7")
|
||||
expect(entry.variant).toBe("high")
|
||||
})
|
||||
|
||||
@@ -534,7 +534,7 @@ describe("ModelRequirement type", () => {
|
||||
// given - a valid ModelRequirement object
|
||||
const requirement: ModelRequirement = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot"], model: "gpt-5.4", variant: "high" },
|
||||
],
|
||||
}
|
||||
@@ -543,7 +543,7 @@ describe("ModelRequirement type", () => {
|
||||
// then - fallbackChain is accessible with correct structure
|
||||
expect(requirement.fallbackChain).toBeArray()
|
||||
expect(requirement.fallbackChain).toHaveLength(2)
|
||||
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-6")
|
||||
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-7")
|
||||
expect(requirement.fallbackChain[1].model).toBe("gpt-5.4")
|
||||
})
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
sisyphus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{
|
||||
providers: [
|
||||
@@ -35,11 +35,12 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"firmware",
|
||||
"ollama-cloud",
|
||||
"aihubmix",
|
||||
"vercel",
|
||||
],
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.4", variant: "medium" },
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode"], model: "big-pickle" },
|
||||
],
|
||||
requiresAnyModel: true,
|
||||
@@ -47,73 +48,73 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
hephaestus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "venice", "opencode"],
|
||||
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
],
|
||||
requiresProvider: ["openai", "github-copilot", "venice", "opencode"],
|
||||
requiresProvider: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
},
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
],
|
||||
},
|
||||
librarian: {
|
||||
fallbackChain: [
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode"], model: "gpt-5-nano" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode", "vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
explore: {
|
||||
fallbackChain: [
|
||||
{ providers: ["github-copilot", "xai"], model: "grok-code-fast-1" },
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode"], model: "gpt-5-nano" },
|
||||
{ providers: ["github-copilot", "xai", "vercel"], model: "grok-code-fast-1" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
"multimodal-looker": {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5-nano" },
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4", variant: "medium" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
prometheus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
},
|
||||
],
|
||||
@@ -121,61 +122,61 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
metis: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "xhigh",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
],
|
||||
},
|
||||
atlas: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode"], model: "big-pickle" },
|
||||
],
|
||||
},
|
||||
@@ -185,54 +186,54 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
ultrabrain: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "opencode"],
|
||||
providers: ["openai", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "xhigh",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
],
|
||||
},
|
||||
deep: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "venice", "opencode"],
|
||||
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
@@ -241,72 +242,72 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4" },
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.4" },
|
||||
],
|
||||
requiresModel: "gemini-3.1-pro",
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode"], model: "gpt-5-nano" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
"unspecified-low": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "opencode"],
|
||||
providers: ["openai", "opencode", "vercel"],
|
||||
model: "gpt-5.3-codex",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
"unspecified-high": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-6",
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode-go"], model: "glm-5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: [
|
||||
"opencode",
|
||||
@@ -315,6 +316,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"firmware",
|
||||
"ollama-cloud",
|
||||
"aihubmix",
|
||||
"vercel",
|
||||
],
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
@@ -323,15 +325,15 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
writing: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("resolveModel", () => {
|
||||
test("returns userModel when all three are set", () => {
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
inheritedModel: "openai/gpt-5.4",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe("resolveModel", () => {
|
||||
const result = resolveModel(input)
|
||||
|
||||
// then
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("returns inheritedModel when userModel is undefined", () => {
|
||||
@@ -91,7 +91,7 @@ describe("resolveModel", () => {
|
||||
test("same input returns same output (referential transparency)", () => {
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
inheritedModel: "openai/gpt-5.4",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
@@ -122,11 +122,11 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/big-pickle",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/big-pickle",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: " ",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -169,16 +169,16 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-7" })
|
||||
})
|
||||
|
||||
test("empty string uiSelectedModel falls through to config override", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,11 +194,11 @@ describe("resolveModelWithFallback", () => {
|
||||
test("returns userModel with override source when userModel is provided", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("override")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-7" })
|
||||
})
|
||||
|
||||
test("override takes priority even if model not in availableModels", () => {
|
||||
@@ -216,9 +216,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "custom/my-model",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -235,9 +235,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: " ",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -253,9 +253,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -272,9 +272,9 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
||||
availableModels: new Set(["github-copilot/claude-opus-4-7-preview", "opencode/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -282,12 +282,12 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-6-preview")
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-7-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider: "github-copilot",
|
||||
model: "claude-opus-4-6",
|
||||
match: "github-copilot/claude-opus-4-6-preview",
|
||||
model: "claude-opus-4-7",
|
||||
match: "github-copilot/claude-opus-4-7-preview",
|
||||
variant: undefined,
|
||||
})
|
||||
})
|
||||
@@ -298,7 +298,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.4" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-6", "google/gemini-3.1-pro"]),
|
||||
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-7", "google/gemini-3.1-pro"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -342,14 +342,14 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("skips fallback chain when not provided", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -381,7 +381,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
@@ -480,7 +480,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined, // no system default configured
|
||||
@@ -517,7 +517,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
@@ -527,7 +527,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should use connected provider (openai) from fallback chain
|
||||
expect(result!.model).toBe("openai/claude-opus-4-6")
|
||||
expect(result!.model).toBe("openai/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -561,14 +561,14 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["openai", "opencode"], model: "claude-haiku-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-opus-4-6-20251101",
|
||||
systemDefaultModel: "anthropic/claude-opus-4-7-20251101",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - no provider in fallback is connected, fall through to system default
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6-20251101")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7-20251101")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -578,7 +578,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
@@ -612,20 +612,20 @@ describe("resolveModelWithFallback", () => {
|
||||
describe("Multi-entry fallbackChain", () => {
|
||||
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
|
||||
// given
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-6"])
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-7"])
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-7", variant: "max" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
@@ -652,14 +652,14 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const availableModels = new Set([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-7",
|
||||
])
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
@@ -678,7 +678,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels,
|
||||
@@ -695,7 +695,7 @@ describe("resolveModelWithFallback", () => {
|
||||
test("result has correct ModelResolutionResult shape", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
@@ -718,7 +718,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
}
|
||||
|
||||
@@ -754,9 +754,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
@@ -764,19 +764,19 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should fall through to fallbackChain
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("userModel takes priority over categoryDefaultModel", () => {
|
||||
// given - both userModel and categoryDefaultModel provided
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
@@ -784,7 +784,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - userModel wins
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
@@ -916,7 +916,7 @@ describe("resolveModelWithFallback", () => {
|
||||
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
userModel: "anthropic/claude-opus-4-7",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
@@ -926,7 +926,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
@@ -934,9 +934,9 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-7"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
@@ -945,7 +945,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("resolveCompatibleModelSettings", () => {
|
||||
test("keeps supported Claude Opus variant unchanged", () => {
|
||||
const result = resolveCompatibleModelSettings({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
desired: { variant: "max" },
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("resolveCompatibleModelSettings", () => {
|
||||
test("uses model metadata first for variant support", () => {
|
||||
const result = resolveCompatibleModelSettings({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
desired: { variant: "max" },
|
||||
capabilities: { variants: ["low", "medium", "high"] },
|
||||
})
|
||||
@@ -42,7 +42,7 @@ describe("resolveCompatibleModelSettings", () => {
|
||||
test("prefers metadata over family heuristics even when family would allow a higher level", () => {
|
||||
const result = resolveCompatibleModelSettings({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
desired: { variant: "max" },
|
||||
capabilities: { variants: ["low", "medium"] },
|
||||
})
|
||||
@@ -514,7 +514,7 @@ describe("resolveCompatibleModelSettings", () => {
|
||||
test("no-op when desired settings are empty", () => {
|
||||
const result = resolveCompatibleModelSettings({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
desired: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
|
||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||
const trimmedModelID = rawModelID.trim()
|
||||
if (!trimmedModelID) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModelID }
|
||||
}
|
||||
|
||||
export function parseModelString(
|
||||
model: string,
|
||||
): { providerID: string; modelID: string; variant?: string } | undefined {
|
||||
const trimmedModel = model.trim()
|
||||
if (!trimmedModel) return undefined
|
||||
|
||||
const separatorIndex = trimmedModel.indexOf("/")
|
||||
if (separatorIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = trimmedModel.slice(0, separatorIndex).trim()
|
||||
const rawModelID = trimmedModel.slice(separatorIndex + 1).trim()
|
||||
if (!providerID || !rawModelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedModel = parseVariantFromModelID(rawModelID)
|
||||
if (!parsedModel.modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedModel.variant
|
||||
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
|
||||
: { providerID, modelID: parsedModel.modelID }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import * as path from "node:path"
|
||||
|
||||
import {
|
||||
_resetProviderAuthCacheForTesting,
|
||||
getProviderAuthType,
|
||||
isProviderUsingOAuth,
|
||||
} from "./opencode-provider-auth"
|
||||
|
||||
describe("opencode-provider-auth", () => {
|
||||
let tempDataDir: string
|
||||
const originalXdgDataHome = process.env.XDG_DATA_HOME
|
||||
|
||||
function writeAuthFile(contents: string): void {
|
||||
const opencodeDir = path.join(tempDataDir, "opencode")
|
||||
mkdirSync(opencodeDir, { recursive: true })
|
||||
writeFileSync(path.join(opencodeDir, "auth.json"), contents, "utf-8")
|
||||
_resetProviderAuthCacheForTesting()
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tempDataDir = path.join(tmpdir(), `opencode-provider-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
mkdirSync(tempDataDir, { recursive: true })
|
||||
process.env.XDG_DATA_HOME = tempDataDir
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = originalXdgDataHome
|
||||
}
|
||||
rmSync(tempDataDir, { recursive: true, force: true })
|
||||
_resetProviderAuthCacheForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetProviderAuthCacheForTesting()
|
||||
})
|
||||
|
||||
it("#given auth.json with oauth entry #then detects OAuth for that provider", () => {
|
||||
// given auth.json where anthropic is OAuth
|
||||
writeAuthFile(JSON.stringify({
|
||||
anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 },
|
||||
opencode: { type: "api", key: "sk-x" },
|
||||
}))
|
||||
|
||||
// when isProviderUsingOAuth queries each provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
const opencodeOauth = isProviderUsingOAuth("opencode")
|
||||
|
||||
// then only OAuth providers return true
|
||||
expect(anthropicOauth).toBe(true)
|
||||
expect(opencodeOauth).toBe(false)
|
||||
})
|
||||
|
||||
it("#given api-key auth.json entry #then returns the api auth type", () => {
|
||||
// given auth.json with an API key for anthropic
|
||||
writeAuthFile(JSON.stringify({ anthropic: { type: "api", key: "sk-ant-xxx" } }))
|
||||
|
||||
// when getProviderAuthType queries the provider
|
||||
const authType = getProviderAuthType("anthropic")
|
||||
|
||||
// then the api type is returned
|
||||
expect(authType).toBe("api")
|
||||
expect(isProviderUsingOAuth("anthropic")).toBe(false)
|
||||
})
|
||||
|
||||
it("#given missing auth.json #then returns undefined with no throw", () => {
|
||||
// given no auth.json exists (XDG_DATA_HOME points to an empty dir)
|
||||
rmSync(path.join(tempDataDir, "opencode"), { recursive: true, force: true })
|
||||
_resetProviderAuthCacheForTesting()
|
||||
|
||||
// when isProviderUsingOAuth queries a provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
const anthropicType = getProviderAuthType("anthropic")
|
||||
|
||||
// then callers get a safe undefined/false
|
||||
expect(anthropicOauth).toBe(false)
|
||||
expect(anthropicType).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given malformed auth.json #then does not throw and returns undefined", () => {
|
||||
// given a malformed JSON auth file
|
||||
writeAuthFile("not json at all")
|
||||
|
||||
// when isProviderUsingOAuth queries a provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
|
||||
// then detection degrades safely
|
||||
expect(anthropicOauth).toBe(false)
|
||||
})
|
||||
|
||||
it("#given unknown provider #then returns undefined", () => {
|
||||
// given auth.json without an entry for the queried provider
|
||||
writeAuthFile(JSON.stringify({ anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 } }))
|
||||
|
||||
// when querying a provider that is not present
|
||||
const openai = getProviderAuthType("openai")
|
||||
|
||||
// then undefined
|
||||
expect(openai).toBeUndefined()
|
||||
expect(isProviderUsingOAuth("openai")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFileSync, statSync } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { getDataDir } from "./data-path"
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Reads OpenCode's auth.json to detect the auth type used by a provider.
|
||||
*
|
||||
* OpenCode stores auth credentials at `<dataDir>/opencode/auth.json` in the
|
||||
* shape `{ [providerID]: { type: "oauth" | "api" | "wellknown", ... } }`.
|
||||
*
|
||||
* The file is read with mtime-based caching so we do not stat/parse it on
|
||||
* every chat.params invocation.
|
||||
*/
|
||||
|
||||
type AuthRecord = {
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
type AuthCacheEntry = {
|
||||
mtimeMs: number
|
||||
map: Map<string, string>
|
||||
}
|
||||
|
||||
let cached: AuthCacheEntry | null = null
|
||||
|
||||
function getAuthFilePath(): string {
|
||||
return path.join(getDataDir(), "opencode", "auth.json")
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function loadAuthMap(): Map<string, string> {
|
||||
const filePath = getAuthFilePath()
|
||||
|
||||
let mtimeMs: number
|
||||
try {
|
||||
mtimeMs = statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
cached = null
|
||||
return new Map()
|
||||
}
|
||||
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.map
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8")
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const map = new Map<string, string>()
|
||||
if (isRecord(parsed)) {
|
||||
for (const [providerID, entry] of Object.entries(parsed)) {
|
||||
if (!isRecord(entry)) continue
|
||||
const type = (entry as AuthRecord).type
|
||||
if (typeof type === "string") {
|
||||
map.set(providerID, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = { mtimeMs, map }
|
||||
return map
|
||||
} catch (error) {
|
||||
log("[opencode-provider-auth] Failed to read auth.json", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderAuthType(providerID: string): string | undefined {
|
||||
return loadAuthMap().get(providerID)
|
||||
}
|
||||
|
||||
export function isProviderUsingOAuth(providerID: string): boolean {
|
||||
return getProviderAuthType(providerID) === "oauth"
|
||||
}
|
||||
|
||||
export function _resetProviderAuthCacheForTesting(): void {
|
||||
cached = null
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolea
|
||||
return false
|
||||
}
|
||||
|
||||
use((request: Request): Request => {
|
||||
use.call(requestInterceptors, (request: Request): Request => {
|
||||
if (!request.headers.get("Authorization")) {
|
||||
request.headers.set("Authorization", auth)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Parses a tools configuration value into a boolean record.
|
||||
* Accepts comma-separated strings, string arrays, or unknown values from config files.
|
||||
* Returns undefined when input is empty or invalid.
|
||||
*/
|
||||
export function parseToolsConfig(toolsValue: unknown): Record<string, boolean> | undefined {
|
||||
if (!toolsValue) return undefined
|
||||
|
||||
let items: string[]
|
||||
if (typeof toolsValue === "string") {
|
||||
items = toolsValue.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
} else if (Array.isArray(toolsValue)) {
|
||||
items = toolsValue.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => (t as string).trim())
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (items.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of items) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -13,12 +13,14 @@ export interface PermissionFormat {
|
||||
* Creates tool restrictions that deny specified tools.
|
||||
*/
|
||||
export function createAgentToolRestrictions(
|
||||
denyTools: string[]
|
||||
denyTools: string[],
|
||||
allowTools: string[] = [],
|
||||
): PermissionFormat {
|
||||
return {
|
||||
permission: Object.fromEntries(
|
||||
denyTools.map((tool) => [tool, "deny" as const])
|
||||
),
|
||||
permission: Object.fromEntries([
|
||||
...denyTools.map((tool) => [tool, "deny" as const]),
|
||||
...allowTools.map((tool) => [tool, "allow" as const]),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
async function importPostHogModule(): Promise<typeof import("./posthog")> {
|
||||
return import(`./posthog?test=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
describe("posthog client creation", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
delete process.env.OMO_DISABLE_POSTHOG
|
||||
delete process.env.OMO_SEND_ANONYMOUS_TELEMETRY
|
||||
delete process.env.POSTHOG_API_KEY
|
||||
delete process.env.POSTHOG_HOST
|
||||
})
|
||||
|
||||
it("returns a no-op client when PostHog construction throws", async () => {
|
||||
// given
|
||||
process.env.OMO_DISABLE_POSTHOG = "0"
|
||||
process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1"
|
||||
process.env.POSTHOG_API_KEY = "test-api-key"
|
||||
|
||||
mock.module("posthog-node", () => ({
|
||||
PostHog: class {
|
||||
constructor() {
|
||||
throw new Error("posthog init failed")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const { createCliPostHog, createPluginPostHog } = await importPostHogModule()
|
||||
|
||||
// when
|
||||
const cliPostHog = createCliPostHog()
|
||||
const pluginPostHog = createPluginPostHog()
|
||||
|
||||
// then
|
||||
expect(() =>
|
||||
cliPostHog.capture({
|
||||
distinctId: "cli",
|
||||
event: "run_started",
|
||||
}),
|
||||
).not.toThrow()
|
||||
expect(() => cliPostHog.captureException(new Error("cli failure"), "cli")).not.toThrow()
|
||||
expect(() => cliPostHog.trackActive("cli", "run_started")).not.toThrow()
|
||||
await expect(cliPostHog.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(() =>
|
||||
pluginPostHog.capture({
|
||||
distinctId: "plugin",
|
||||
event: "plugin_loaded",
|
||||
}),
|
||||
).not.toThrow()
|
||||
expect(() => pluginPostHog.captureException(new Error("plugin failure"), "plugin")).not.toThrow()
|
||||
expect(() => pluginPostHog.trackActive("plugin", "plugin_loaded")).not.toThrow()
|
||||
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
+13
-7
@@ -87,11 +87,17 @@ function createPostHogClient(
|
||||
return NO_OP_POSTHOG
|
||||
}
|
||||
|
||||
const configuredClient = new PostHog(getPostHogApiKey(), {
|
||||
...options,
|
||||
host: getPostHogHost(),
|
||||
disableGeoip: false,
|
||||
})
|
||||
let configuredClient: PostHog
|
||||
|
||||
try {
|
||||
configuredClient = new PostHog(getPostHogApiKey(), {
|
||||
...options,
|
||||
host: getPostHogHost(),
|
||||
disableGeoip: false,
|
||||
})
|
||||
} catch {
|
||||
return NO_OP_POSTHOG
|
||||
}
|
||||
const sharedProperties = getSharedProperties(source)
|
||||
|
||||
return {
|
||||
@@ -149,7 +155,7 @@ export function getPostHogDistinctId(): string {
|
||||
|
||||
export function createCliPostHog(): PostHogClient {
|
||||
return createPostHogClient("cli", {
|
||||
enableExceptionAutocapture: true,
|
||||
enableExceptionAutocapture: false,
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
})
|
||||
@@ -157,7 +163,7 @@ export function createCliPostHog(): PostHogClient {
|
||||
|
||||
export function createPluginPostHog(): PostHogClient {
|
||||
return createPostHogClient("plugin", {
|
||||
enableExceptionAutocapture: true,
|
||||
enableExceptionAutocapture: false,
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, realpathSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
findProjectAgentsSkillDirs,
|
||||
findProjectClaudeSkillDirs,
|
||||
findProjectOpencodeCommandDirs,
|
||||
findProjectOpencodeSkillDirs,
|
||||
} from "./project-discovery-dirs"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
||||
let worktreeSpawnCount = 0
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
return realpathSync(path)
|
||||
@@ -24,7 +19,35 @@ describe("project-discovery-dirs", () => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", () => {
|
||||
it("#given repeated worktree detection #when detecting twice #then reuses the cached result", async () => {
|
||||
// given
|
||||
worktreeSpawnCount = 0
|
||||
|
||||
mock.module("node:child_process", () => ({
|
||||
execFileSync: () => {
|
||||
worktreeSpawnCount += 1
|
||||
return TEST_DIR
|
||||
},
|
||||
}))
|
||||
|
||||
const { clearWorktreeCache, detectWorktreePath } = await import("./project-discovery-dirs")
|
||||
|
||||
clearWorktreeCache()
|
||||
|
||||
// when
|
||||
const firstPath = detectWorktreePath("/some/dir")
|
||||
const secondPath = detectWorktreePath("/some/dir")
|
||||
clearWorktreeCache()
|
||||
const thirdPath = detectWorktreePath("/some/dir")
|
||||
|
||||
// then
|
||||
expect(firstPath).toBe(TEST_DIR)
|
||||
expect(secondPath).toBe(TEST_DIR)
|
||||
expect(thirdPath).toBe(TEST_DIR)
|
||||
expect(worktreeSpawnCount).toBe(2)
|
||||
})
|
||||
|
||||
it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "cli")
|
||||
@@ -32,6 +55,8 @@ describe("project-discovery-dirs", () => {
|
||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||
|
||||
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeSkillDirs(childDir)
|
||||
|
||||
@@ -43,13 +68,15 @@ describe("project-discovery-dirs", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", () => {
|
||||
it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "packages", "tool")
|
||||
mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true })
|
||||
|
||||
const { findProjectOpencodeCommandDirs } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeCommandDirs(childDir)
|
||||
|
||||
@@ -60,13 +87,15 @@ describe("project-discovery-dirs", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", () => {
|
||||
it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "src", "nested")
|
||||
mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true })
|
||||
|
||||
const { findProjectAgentsSkillDirs, findProjectClaudeSkillDirs } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const claudeDirectories = findProjectClaudeSkillDirs(childDir)
|
||||
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
||||
@@ -76,17 +105,20 @@ describe("project-discovery-dirs", () => {
|
||||
expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))])
|
||||
})
|
||||
|
||||
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => {
|
||||
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "cli")
|
||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||
|
||||
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
const worktreePathCache = new Map<string, string | undefined>()
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const resolvedPath = resolve(path)
|
||||
if (!existsSync(resolvedPath)) {
|
||||
@@ -49,15 +51,28 @@ function findAncestorDirectories(
|
||||
}
|
||||
}
|
||||
|
||||
function detectWorktreePath(directory: string): string | undefined {
|
||||
export function clearWorktreeCache(): void {
|
||||
worktreePathCache.clear()
|
||||
}
|
||||
|
||||
export function detectWorktreePath(directory: string): string | undefined {
|
||||
const resolvedDirectory = resolve(directory)
|
||||
if (worktreePathCache.has(resolvedDirectory)) {
|
||||
return worktreePathCache.get(resolvedDirectory)
|
||||
}
|
||||
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: directory,
|
||||
const worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: resolvedDirectory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
worktreePathCache.set(resolvedDirectory, worktreePath)
|
||||
return worktreePath
|
||||
} catch {
|
||||
worktreePathCache.set(resolvedDirectory, undefined)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,58 @@
|
||||
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 === "github-copilot") {
|
||||
// Vercel AI Gateway expects <sub-provider>/<model> (e.g. anthropic/claude-opus-4.7).
|
||||
// Canonical names in model-requirements.ts may be bare (claude-opus-4-7) or
|
||||
// already prefixed (anthropic/claude-opus-4-7). Both need gateway-specific transforms.
|
||||
if (provider === "vercel") {
|
||||
// Already prefixed — transform only the model part
|
||||
const slashIndex = model.indexOf("/")
|
||||
if (slashIndex !== -1) {
|
||||
const subProvider = model.substring(0, slashIndex)
|
||||
const subModel = model.substring(slashIndex + 1)
|
||||
return `${subProvider}/${applyGatewayTransforms(subModel)}`
|
||||
}
|
||||
// Bare name — infer sub-provider from model prefix (claude- → anthropic, etc.)
|
||||
const subProvider = inferSubProvider(model)
|
||||
if (subProvider) {
|
||||
return `${subProvider}/${applyGatewayTransforms(model)}`
|
||||
}
|
||||
return model
|
||||
.replace("claude-opus-4-6", "claude-opus-4.6")
|
||||
.replace("claude-sonnet-4-6", "claude-sonnet-4.6")
|
||||
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
|
||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||
.replace("claude-sonnet-4", "claude-sonnet-4")
|
||||
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||
}
|
||||
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-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return model
|
||||
.replace("claude-opus-4-6", "claude-opus-4.6")
|
||||
.replace("claude-sonnet-4-6", "claude-sonnet-4.6")
|
||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||
return claudeVersionDot(model)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { resolveAgentDefinitionPaths } from "./resolve-agent-definition-paths"
|
||||
|
||||
describe("resolveAgentDefinitionPaths", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "resolve-agent-def-paths-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given relative paths", () => {
|
||||
test("#then they are resolved against baseDir", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["agents/my-agent.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(tempDir, "agents/my-agent.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given absolute paths", () => {
|
||||
test("#then they are returned as-is", () => {
|
||||
const absPath = join(tempDir, "absolute-agent.md")
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
[absPath],
|
||||
"/some/other/base",
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([absPath])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tilde-prefixed paths", () => {
|
||||
test("#then ~ is expanded to homedir", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["~/agents/test.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(homedir(), "agents/test.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given containmentDir is set", () => {
|
||||
test("#then paths outside the boundary are rejected", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["/etc/passwd"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("#then paths inside the boundary are allowed", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
const agentsDir = join(projectDir, "agents")
|
||||
mkdirSync(agentsDir, { recursive: true })
|
||||
writeFileSync(join(agentsDir, "a.md"), "test", "utf-8")
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["agents/a.md"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(projectDir, "agents/a.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given containmentDir is null", () => {
|
||||
test("#then no boundary check is applied", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["/some/outside/path/agent.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual(["/some/outside/path/agent.md"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an empty paths array", () => {
|
||||
test("#then an empty array is returned", () => {
|
||||
const result = resolveAgentDefinitionPaths([], tempDir, null)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given mixed valid and invalid paths", () => {
|
||||
test("#then only valid paths within the boundary are returned", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["./valid.md", "/outside/boundary.md"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(projectDir, "valid.md")])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { homedir } from "os"
|
||||
import { isAbsolute, resolve } from "path"
|
||||
import { isWithinProject } from "./contains-path"
|
||||
import { log } from "./logger"
|
||||
|
||||
export function resolveAgentDefinitionPaths(
|
||||
paths: string[],
|
||||
baseDir: string,
|
||||
containmentDir: string | null
|
||||
): string[] {
|
||||
return paths.flatMap((p) => {
|
||||
const expanded = p.startsWith("~/") ? p.replace(/^~\//, `${homedir()}/`) : p
|
||||
const resolved = isAbsolute(expanded) ? expanded : resolve(baseDir, expanded)
|
||||
|
||||
if (containmentDir !== null && !isWithinProject(resolved, containmentDir)) {
|
||||
log(`agent_definitions path rejected (outside project boundary): ${p} -> ${resolved}`)
|
||||
return []
|
||||
}
|
||||
|
||||
return [resolved]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "../tools/grep/downloader"
|
||||
import { getDataDir } from "./data-path"
|
||||
import { log } from "./logger"
|
||||
import { PUBLISHED_PACKAGE_NAME } from "./plugin-identity"
|
||||
|
||||
export type GrepBackend = "rg" | "grep"
|
||||
|
||||
export interface ResolvedCli {
|
||||
path: string
|
||||
backend: GrepBackend
|
||||
}
|
||||
|
||||
export const DEFAULT_RG_THREADS = 4
|
||||
|
||||
let cachedCli: ResolvedCli | null = null
|
||||
let autoInstallAttempted = false
|
||||
|
||||
function findExecutable(name: string): string | null {
|
||||
const isWindows = process.platform === "win32"
|
||||
const cmd = isWindows ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getOpenCodeBundledRg(): string | null {
|
||||
const execPath = process.execPath
|
||||
const execDir = dirname(execPath)
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const rgName = isWindows ? "rg.exe" : "rg"
|
||||
|
||||
const candidates = [
|
||||
join(getDataDir(), "opencode", "bin", rgName),
|
||||
join(execDir, rgName),
|
||||
join(execDir, "bin", rgName),
|
||||
join(execDir, "..", "bin", rgName),
|
||||
join(execDir, "..", "libexec", rgName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveGrepCli(): ResolvedCli {
|
||||
if (cachedCli) {
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
const rgPath = getOpenCodeBundledRg() ?? findExecutable("rg") ?? getInstalledRipgrepPath()
|
||||
if (rgPath) {
|
||||
cachedCli = { path: rgPath, backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
const grep = findExecutable("grep")
|
||||
if (grep) {
|
||||
cachedCli = { path: grep, backend: "grep" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
cachedCli = { path: "rg", backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
||||
const current = resolveGrepCli()
|
||||
|
||||
if (current.backend === "rg" && current.path !== "rg") {
|
||||
return current
|
||||
}
|
||||
|
||||
if (autoInstallAttempted) {
|
||||
return current
|
||||
}
|
||||
|
||||
autoInstallAttempted = true
|
||||
|
||||
try {
|
||||
const rgPath = await downloadAndInstallRipgrep()
|
||||
cachedCli = { path: rgPath, backend: "rg" }
|
||||
return cachedCli
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (current.backend === "grep") {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
|
||||
error: message,
|
||||
grep_path: current.path,
|
||||
})
|
||||
} else {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ describe("shell-env", () => {
|
||||
originalEnv = {
|
||||
SHELL: process.env.SHELL,
|
||||
PSModulePath: process.env.PSModulePath,
|
||||
MSYSTEM: process.env.MSYSTEM,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,6 +48,7 @@ describe("shell-env", () => {
|
||||
|
||||
test("#given PSModulePath is set without SHELL #when detectShellType is called #then returns powershell", () => {
|
||||
delete process.env.SHELL
|
||||
delete process.env.MSYSTEM
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
@@ -58,6 +60,7 @@ describe("shell-env", () => {
|
||||
test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
delete process.env.MSYSTEM
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
@@ -68,6 +71,7 @@ describe("shell-env", () => {
|
||||
test("#given non-Windows platform without SHELL env var #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
delete process.env.MSYSTEM
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const result = detectShellType()
|
||||
@@ -94,6 +98,28 @@ describe("shell-env", () => {
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given MSYSTEM set on Windows without SHELL #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.SHELL
|
||||
process.env.MSYSTEM = "MINGW64"
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given MSYSTEM set to MSYS without SHELL #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.SHELL
|
||||
process.env.MSYSTEM = "MSYS"
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shellEscape", () => {
|
||||
|
||||
@@ -29,8 +29,8 @@ export function detectShellType(): ShellType {
|
||||
if (
|
||||
process.platform === "win32" &&
|
||||
(process.env.BASH_VERSION ||
|
||||
process.env.MSYSTEM ||
|
||||
process.env.WSL_DISTRO_NAME)
|
||||
process.env.MSYSTEM ||
|
||||
process.env.WSL_DISTRO_NAME)
|
||||
) {
|
||||
return "unix"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
|
||||
export { closeTmuxPane } from "./tmux-utils/pane-close"
|
||||
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
|
||||
export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
|
||||
export { spawnTmuxSession } from "./tmux-utils/session-spawn"
|
||||
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
|
||||
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill"
|
||||
export { sweepStaleOmoAgentSessions } from "./tmux-utils/stale-session-sweep"
|
||||
|
||||
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { killTmuxSessionIfExists } from "./session-kill"
|
||||
@@ -0,0 +1,221 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
type CloseTmuxPane = typeof import("./pane-close").closeTmuxPane
|
||||
|
||||
type SpawnCall = {
|
||||
command: string[]
|
||||
options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
}
|
||||
|
||||
type FakeSubprocess = {
|
||||
exited: Promise<number>
|
||||
stdout: ReadableStream<Uint8Array>
|
||||
stderr: ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
const TIMEOUT = Symbol("timeout")
|
||||
const spawnCalls: SpawnCall[] = []
|
||||
const queuedProcesses: FakeSubprocess[] = []
|
||||
|
||||
function createClosedStream(): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type DrainSignal = { onPull: () => void }
|
||||
|
||||
function createDrainSensitiveStream(byteLength: number, signal: DrainSignal): ReadableStream<Uint8Array> {
|
||||
let remainingBytes = byteLength
|
||||
const chunk = new TextEncoder().encode("x".repeat(16 * 1024))
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
signal.onPull()
|
||||
|
||||
if (remainingBytes <= 0) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const nextChunkSize = Math.min(remainingBytes, chunk.byteLength)
|
||||
controller.enqueue(chunk.subarray(0, nextChunkSize))
|
||||
remainingBytes -= nextChunkSize
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createProcess(exitCode: number): FakeSubprocess {
|
||||
return {
|
||||
exited: Promise.resolve(exitCode),
|
||||
stdout: createClosedStream(),
|
||||
stderr: createClosedStream(),
|
||||
}
|
||||
}
|
||||
|
||||
function createStdoutSensitiveProcess(exitCode: number, stdoutBytes: number): FakeSubprocess {
|
||||
let resolveDrained: () => void = () => undefined
|
||||
const drained = new Promise<void>((resolve) => {
|
||||
resolveDrained = resolve
|
||||
})
|
||||
const stdout = createDrainSensitiveStream(stdoutBytes, { onPull: () => resolveDrained() })
|
||||
|
||||
return {
|
||||
exited: drained.then(() => exitCode),
|
||||
stdout,
|
||||
stderr: createClosedStream(),
|
||||
}
|
||||
}
|
||||
|
||||
const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}): FakeSubprocess => {
|
||||
spawnCalls.push({ command, options })
|
||||
|
||||
const process = queuedProcesses.shift()
|
||||
if (!process) {
|
||||
throw new Error(`No fake subprocess configured for ${command.join(" ")}`)
|
||||
}
|
||||
|
||||
return process
|
||||
})
|
||||
|
||||
const isInsideTmuxMock = mock((): boolean => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
|
||||
const logMock = mock(() => undefined)
|
||||
|
||||
const paneCloseSpecifier = import.meta.resolve("./pane-close")
|
||||
const environmentSpecifier = import.meta.resolve("./environment")
|
||||
const loggerSpecifier = import.meta.resolve("../../logger")
|
||||
const spawnProcessSpecifier = import.meta.resolve("./spawn-process")
|
||||
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
|
||||
|
||||
async function loadCloseTmuxPane(): Promise<CloseTmuxPane> {
|
||||
const module = await import(`${paneCloseSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.closeTmuxPane
|
||||
}
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock }))
|
||||
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
|
||||
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
|
||||
mock.module(loggerSpecifier, () => ({ log: logMock }))
|
||||
}
|
||||
|
||||
function resolveWithin<TResult>(promise: Promise<TResult>, milliseconds: number): Promise<TResult | typeof TIMEOUT> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<typeof TIMEOUT>((resolve) => {
|
||||
setTimeout(() => resolve(TIMEOUT), milliseconds)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
describe("closeTmuxPane", () => {
|
||||
beforeEach(() => {
|
||||
registerModuleMocks()
|
||||
spawnCalls.length = 0
|
||||
queuedProcesses.length = 0
|
||||
spawnMock.mockClear()
|
||||
isInsideTmuxMock.mockClear()
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
isInsideTmuxMock.mockImplementation((): boolean => true)
|
||||
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => "tmux")
|
||||
})
|
||||
|
||||
it("#given pane exists #when closeTmuxPane called #then returns true and invokes send-keys + kill-pane in order", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
queuedProcesses.push(createProcess(0), createProcess(0))
|
||||
|
||||
// when
|
||||
const result = await closeTmuxPane("%42")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
expect(spawnCalls).toEqual([
|
||||
{ command: ["tmux", "send-keys", "-t", "%42", "C-c"], options: { stdout: "ignore", stderr: "ignore" } },
|
||||
{ command: ["tmux", "kill-pane", "-t", "%42"], options: { stdout: "pipe", stderr: "pipe" } },
|
||||
])
|
||||
})
|
||||
|
||||
it("#given not inside tmux #when closeTmuxPane called #then returns false without spawn", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
isInsideTmuxMock.mockImplementation((): boolean => false)
|
||||
|
||||
// when
|
||||
const result = await closeTmuxPane("%42")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#given tmux not found #when closeTmuxPane called #then returns false without spawn", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => undefined)
|
||||
|
||||
// when
|
||||
const result = await closeTmuxPane("%42")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
queuedProcesses.push(createProcess(0), createProcess(1))
|
||||
|
||||
// when
|
||||
const result = await closeTmuxPane("%42")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#given pane already closed by Ctrl+C (kill-pane reports 'can't find pane') #when closeTmuxPane called #then returns true", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
queuedProcesses.push(
|
||||
createProcess(0),
|
||||
{
|
||||
exited: Promise.resolve(1),
|
||||
stdout: createClosedStream(),
|
||||
stderr: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("can't find pane: %42\n"))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// when
|
||||
const result = await closeTmuxPane("%42")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given kill-pane stdout stream waits for drain #when closeTmuxPane called #then returns true once drainer consumes stdout", async () => {
|
||||
// given
|
||||
const closeTmuxPane = await loadCloseTmuxPane()
|
||||
queuedProcesses.push(createProcess(0), createStdoutSensitiveProcess(0, 16 * 1024))
|
||||
|
||||
// when
|
||||
const result = await resolveWithin(closeTmuxPane("%42"), 2000)
|
||||
|
||||
// then
|
||||
expect(result).not.toBe(TIMEOUT)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawn } from "bun"
|
||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { isInsideTmux } from "./environment"
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array> | null | undefined): Promise<string> {
|
||||
return stream ? new Response(stream).text() : ""
|
||||
}
|
||||
|
||||
export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
||||
const { log } = await import("../../logger")
|
||||
const [{ log }, { isInsideTmux }, { getTmuxPath }, { spawn }] = await Promise.all([
|
||||
import("../../logger"),
|
||||
import("./environment"),
|
||||
import("../../../tools/interactive-bash/tmux-path-resolver"),
|
||||
import("./spawn-process"),
|
||||
])
|
||||
|
||||
if (!isInsideTmux()) {
|
||||
log("[closeTmuxPane] SKIP: not inside tmux")
|
||||
@@ -22,8 +27,8 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
||||
|
||||
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
await ctrlCProc.exited
|
||||
|
||||
@@ -31,18 +36,29 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
||||
|
||||
log("[closeTmuxPane] killing pane", { paneId })
|
||||
|
||||
const proc = spawn([tmux, "kill-pane", "-t", paneId], {
|
||||
const killPaneProc = spawn([tmux, "kill-pane", "-t", paneId], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const exitCode = await proc.exited
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const [, stderr, exitCode] = await Promise.all([
|
||||
readStream(killPaneProc.stdout),
|
||||
readStream(killPaneProc.stderr),
|
||||
killPaneProc.exited,
|
||||
])
|
||||
|
||||
if (exitCode !== 0) {
|
||||
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
|
||||
} else {
|
||||
log("[closeTmuxPane] SUCCESS", { paneId })
|
||||
const trimmedStderr = stderr.trim()
|
||||
const paneAlreadyGone = exitCode !== 0 && /can't find pane/i.test(trimmedStderr)
|
||||
|
||||
if (paneAlreadyGone) {
|
||||
log("[closeTmuxPane] SUCCESS (pane already closed by Ctrl+C)", { paneId })
|
||||
return true
|
||||
}
|
||||
|
||||
return exitCode === 0
|
||||
if (exitCode !== 0) {
|
||||
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: trimmedStderr })
|
||||
return false
|
||||
}
|
||||
|
||||
log("[closeTmuxPane] SUCCESS", { paneId })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
type KillTmuxSessionIfExists = typeof import("./session-kill").killTmuxSessionIfExists
|
||||
|
||||
type SpawnCall = {
|
||||
command: string[]
|
||||
options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
}
|
||||
|
||||
type FakeSubprocess = {
|
||||
exited: Promise<number>
|
||||
stdout: ReadableStream<Uint8Array>
|
||||
stderr: ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
const spawnCalls: SpawnCall[] = []
|
||||
const queuedProcesses: FakeSubprocess[] = []
|
||||
|
||||
function createStream(chunks: string[] = []): ReadableStream<Uint8Array> {
|
||||
const textEncoder = new TextEncoder()
|
||||
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(textEncoder.encode(chunk))
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createProcess(exitCode: number, output: { stdout?: string[]; stderr?: string[] } = {}): FakeSubprocess {
|
||||
return {
|
||||
exited: Promise.resolve(exitCode),
|
||||
stdout: createStream(output.stdout),
|
||||
stderr: createStream(output.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}) => {
|
||||
spawnCalls.push({ command, options })
|
||||
|
||||
const process = queuedProcesses.shift()
|
||||
if (!process) {
|
||||
throw new Error(`No fake subprocess configured for ${command.join(" ")}`)
|
||||
}
|
||||
|
||||
return process
|
||||
})
|
||||
|
||||
const isInsideTmuxMock = mock((): boolean => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
|
||||
const logMock = mock(() => undefined)
|
||||
|
||||
const sessionKillSpecifier = import.meta.resolve("./session-kill")
|
||||
const environmentSpecifier = import.meta.resolve("./environment")
|
||||
const loggerSpecifier = import.meta.resolve("../../logger")
|
||||
const spawnProcessSpecifier = import.meta.resolve("./spawn-process")
|
||||
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
|
||||
|
||||
async function loadKillTmuxSessionIfExists(): Promise<typeof KillTmuxSessionIfExists> {
|
||||
const module = await import(`${sessionKillSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.killTmuxSessionIfExists
|
||||
}
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock }))
|
||||
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
|
||||
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
|
||||
mock.module(loggerSpecifier, () => ({ log: logMock }))
|
||||
}
|
||||
|
||||
describe("killTmuxSessionIfExists", () => {
|
||||
beforeEach(() => {
|
||||
registerModuleMocks()
|
||||
spawnCalls.length = 0
|
||||
queuedProcesses.length = 0
|
||||
spawnMock.mockClear()
|
||||
isInsideTmuxMock.mockClear()
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
isInsideTmuxMock.mockImplementation((): boolean => true)
|
||||
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => "tmux")
|
||||
})
|
||||
|
||||
it("#given omo-agents session exists #when killTmuxSessionIfExists called #then kill-session invoked and returns true", async () => {
|
||||
// given
|
||||
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
|
||||
queuedProcesses.push(createProcess(0), createProcess(0, { stdout: ["killed"], stderr: [] }))
|
||||
|
||||
// when
|
||||
const result = await killTmuxSessionIfExists("omo-agents")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
expect(spawnCalls).toEqual([
|
||||
{
|
||||
command: ["tmux", "has-session", "-t", "omo-agents"],
|
||||
options: { stdout: "ignore", stderr: "ignore" },
|
||||
},
|
||||
{
|
||||
command: ["tmux", "kill-session", "-t", "omo-agents"],
|
||||
options: { stdout: "pipe", stderr: "pipe" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("#given omo-agents session does NOT exist (has-session exits non-zero) #when killTmuxSessionIfExists called #then NO kill-session invocation and returns false", async () => {
|
||||
// given
|
||||
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
|
||||
queuedProcesses.push(createProcess(1))
|
||||
|
||||
// when
|
||||
const result = await killTmuxSessionIfExists("omo-agents")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toEqual([
|
||||
{
|
||||
command: ["tmux", "has-session", "-t", "omo-agents"],
|
||||
options: { stdout: "ignore", stderr: "ignore" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("#given not inside tmux #when killTmuxSessionIfExists called #then returns false without any spawn", async () => {
|
||||
// given
|
||||
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
|
||||
isInsideTmuxMock.mockReturnValue(false)
|
||||
|
||||
// when
|
||||
const result = await killTmuxSessionIfExists("omo-agents")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toHaveLength(0)
|
||||
expect(getTmuxPathMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("#given tmux not found #when killTmuxSessionIfExists called #then returns false without spawn", async () => {
|
||||
// given
|
||||
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
|
||||
getTmuxPathMock.mockResolvedValue(undefined)
|
||||
|
||||
// when
|
||||
const result = await killTmuxSessionIfExists("omo-agents")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#given kill-session itself fails (e.g., race between has-session and kill) #when killTmuxSessionIfExists called #then returns false but does not throw", async () => {
|
||||
// given
|
||||
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
|
||||
queuedProcesses.push(
|
||||
createProcess(0),
|
||||
createProcess(1, { stdout: [], stderr: ["no session"] }),
|
||||
)
|
||||
|
||||
// when
|
||||
const result = await killTmuxSessionIfExists("omo-agents")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(spawnCalls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
async function readStream(stream: ReadableStream<Uint8Array> | null | undefined): Promise<string> {
|
||||
return stream ? new Response(stream).text() : ""
|
||||
}
|
||||
|
||||
export async function killTmuxSessionIfExists(sessionName: string): Promise<boolean> {
|
||||
const [{ log }, { isInsideTmux }, { getTmuxPath }, { spawn }] = await Promise.all([
|
||||
import("../../logger"),
|
||||
import("./environment"),
|
||||
import("../../../tools/interactive-bash/tmux-path-resolver"),
|
||||
import("./spawn-process"),
|
||||
])
|
||||
|
||||
if (!isInsideTmux()) {
|
||||
log("[killTmuxSessionIfExists] SKIP: not inside tmux", { sessionName })
|
||||
return false
|
||||
}
|
||||
|
||||
const tmux = await getTmuxPath()
|
||||
if (!tmux) {
|
||||
log("[killTmuxSessionIfExists] SKIP: tmux not found", { sessionName })
|
||||
return false
|
||||
}
|
||||
|
||||
const hasSessionProcess = spawn([tmux, "has-session", "-t", sessionName], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
if ((await hasSessionProcess.exited) !== 0) {
|
||||
log("[killTmuxSessionIfExists] SKIP: session not found", { sessionName })
|
||||
return false
|
||||
}
|
||||
|
||||
const killSessionProcess = spawn([tmux, "kill-session", "-t", sessionName], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [, stderr, exitCode] = await Promise.all([
|
||||
readStream(killSessionProcess.stdout),
|
||||
readStream(killSessionProcess.stderr),
|
||||
killSessionProcess.exited,
|
||||
])
|
||||
|
||||
if (exitCode !== 0) {
|
||||
log("[killTmuxSessionIfExists] FAILED", { sessionName, exitCode, stderr: stderr.trim() })
|
||||
return false
|
||||
}
|
||||
|
||||
log("[killTmuxSessionIfExists] SUCCESS", { sessionName })
|
||||
return true
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import { isInsideTmux } from "./environment"
|
||||
import { isServerRunning } from "./server-health"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
const ISOLATED_SESSION_NAME = "omo-agents"
|
||||
const ISOLATED_SESSION_NAME_PREFIX = "omo-agents"
|
||||
|
||||
export function getIsolatedSessionName(pid: number = process.pid): string {
|
||||
return `${ISOLATED_SESSION_NAME_PREFIX}-${pid}`
|
||||
}
|
||||
|
||||
async function getWindowDimensions(
|
||||
tmux: string,
|
||||
@@ -87,12 +91,13 @@ export async function spawnTmuxSession(
|
||||
}
|
||||
}
|
||||
|
||||
const sessionAlreadyExists = await sessionExists(tmux, ISOLATED_SESSION_NAME)
|
||||
const isolatedSessionName = getIsolatedSessionName()
|
||||
const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName)
|
||||
|
||||
const args = sessionAlreadyExists
|
||||
? [
|
||||
"new-window",
|
||||
"-t", ISOLATED_SESSION_NAME,
|
||||
"-t", isolatedSessionName,
|
||||
"-P",
|
||||
"-F", "#{pane_id}",
|
||||
opencodeCmd,
|
||||
@@ -100,7 +105,7 @@ export async function spawnTmuxSession(
|
||||
: [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s", ISOLATED_SESSION_NAME,
|
||||
"-s", isolatedSessionName,
|
||||
...sizeArgs,
|
||||
"-P",
|
||||
"-F", "#{pane_id}",
|
||||
@@ -109,7 +114,7 @@ export async function spawnTmuxSession(
|
||||
|
||||
log("[spawnTmuxSession] spawning", {
|
||||
mode: sessionAlreadyExists ? "new-window" : "new-session",
|
||||
sessionName: ISOLATED_SESSION_NAME,
|
||||
sessionName: isolatedSessionName,
|
||||
})
|
||||
|
||||
const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
@@ -140,6 +145,6 @@ export async function spawnTmuxSession(
|
||||
})
|
||||
}
|
||||
|
||||
log("[spawnTmuxSession] SUCCESS", { paneId, sessionName: ISOLATED_SESSION_NAME })
|
||||
log("[spawnTmuxSession] SUCCESS", { paneId, sessionName: isolatedSessionName })
|
||||
return { success: true, paneId }
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { spawn } from "bun"
|
||||
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { sweepStaleOmoAgentSessionsWith, type SweepDeps } from "./stale-session-sweep"
|
||||
|
||||
type SweepFixture = {
|
||||
deps: SweepDeps
|
||||
candidates: string[]
|
||||
killed: string[]
|
||||
killSessionMock: ReturnType<typeof mock>
|
||||
setCandidates: (sessions: string[]) => void
|
||||
setAlive: (predicate: (pid: number) => boolean) => void
|
||||
}
|
||||
|
||||
function createFixture(): SweepFixture {
|
||||
const candidates: string[] = []
|
||||
const killed: string[] = []
|
||||
let aliveCheck: (pid: number) => boolean = () => false
|
||||
|
||||
const killSessionMock = mock(async (sessionName: string): Promise<boolean> => {
|
||||
killed.push(sessionName)
|
||||
return true
|
||||
})
|
||||
|
||||
const deps: SweepDeps = {
|
||||
isInsideTmux: () => true,
|
||||
getTmuxPath: async () => "tmux",
|
||||
listCandidateSessions: async () => [...candidates],
|
||||
killSession: killSessionMock,
|
||||
processAlive: (pid) => aliveCheck(pid),
|
||||
currentPid: 12345,
|
||||
log: () => undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
deps,
|
||||
candidates,
|
||||
killed,
|
||||
killSessionMock,
|
||||
setCandidates: (sessions) => {
|
||||
candidates.length = 0
|
||||
candidates.push(...sessions)
|
||||
},
|
||||
setAlive: (predicate) => {
|
||||
aliveCheck = predicate
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("sweepStaleOmoAgentSessionsWith", () => {
|
||||
let fixture: SweepFixture
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = createFixture()
|
||||
})
|
||||
|
||||
it("#given not inside tmux #when sweep called #then returns 0 without listing", async () => {
|
||||
// given
|
||||
const deps: SweepDeps = { ...fixture.deps, isInsideTmux: () => false }
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("#given tmux not found #when sweep called #then returns 0 without listing", async () => {
|
||||
// given
|
||||
const deps: SweepDeps = { ...fixture.deps, getTmuxPath: async () => undefined }
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("#given candidate list is empty #when sweep called #then returns 0 and does not kill anything", async () => {
|
||||
// given
|
||||
fixture.setCandidates([])
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
expect(fixture.killed).toEqual([])
|
||||
})
|
||||
|
||||
it("#given sessions with dead PIDs #when sweep called #then each dead session is killed once", async () => {
|
||||
// given
|
||||
fixture.setCandidates(["omo-agents-99991", "omo-agents-99992"])
|
||||
fixture.setAlive(() => false)
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(2)
|
||||
expect(fixture.killed).toEqual(["omo-agents-99991", "omo-agents-99992"])
|
||||
})
|
||||
|
||||
it("#given session matches current PID #when sweep called #then it is NOT killed", async () => {
|
||||
// given
|
||||
fixture.setCandidates([`omo-agents-${fixture.deps.currentPid}`, "omo-agents-99999"])
|
||||
fixture.setAlive(() => false)
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(1)
|
||||
expect(fixture.killed).toEqual(["omo-agents-99999"])
|
||||
})
|
||||
|
||||
it("#given session PID is still alive #when sweep called #then it is NOT killed", async () => {
|
||||
// given
|
||||
fixture.setCandidates(["omo-agents-88888"])
|
||||
fixture.setAlive((pid) => pid === 88888)
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
expect(fixture.killed).toEqual([])
|
||||
})
|
||||
|
||||
it("#given killSession returns false #when sweep called #then session is not counted toward killedCount", async () => {
|
||||
// given
|
||||
fixture.setCandidates(["omo-agents-55555"])
|
||||
fixture.setAlive(() => false)
|
||||
fixture.killSessionMock.mockImplementation(async () => false)
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(0)
|
||||
expect(fixture.killSessionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("#given non-matching sessions mixed in #when sweep called #then only omo-agents-<pid> sessions are considered", async () => {
|
||||
// given
|
||||
fixture.setCandidates(["main", "omo-agents-99999", "other-session", "omo-agents-abc"])
|
||||
fixture.setAlive(() => false)
|
||||
|
||||
// when
|
||||
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||
|
||||
// then
|
||||
expect(result).toBe(1)
|
||||
expect(fixture.killed).toEqual(["omo-agents-99999"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
return err?.code === "EPERM"
|
||||
}
|
||||
}
|
||||
|
||||
async function listOmoAgentSessionsViaTmux(tmux: string): Promise<string[]> {
|
||||
const { spawn } = await import("./spawn-process")
|
||||
const proc = spawn([tmux, "list-sessions", "-F", "#{session_name}"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, , exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((name) => STALE_SESSION_PATTERN.test(name))
|
||||
}
|
||||
|
||||
export type SweepDeps = {
|
||||
isInsideTmux: () => boolean
|
||||
getTmuxPath: () => Promise<string | null | undefined>
|
||||
listCandidateSessions: (tmux: string) => Promise<string[]>
|
||||
killSession: (sessionName: string) => Promise<boolean>
|
||||
processAlive: (pid: number) => boolean
|
||||
currentPid: number
|
||||
log: (message: string, payload?: unknown) => void
|
||||
}
|
||||
|
||||
async function buildRuntimeDeps(): Promise<SweepDeps> {
|
||||
const [{ log }, { isInsideTmux }, { getTmuxPath }, { killTmuxSessionIfExists }] = await Promise.all([
|
||||
import("../../logger"),
|
||||
import("./environment"),
|
||||
import("../../../tools/interactive-bash/tmux-path-resolver"),
|
||||
import("./session-kill"),
|
||||
])
|
||||
|
||||
return {
|
||||
isInsideTmux,
|
||||
getTmuxPath,
|
||||
listCandidateSessions: listOmoAgentSessionsViaTmux,
|
||||
killSession: killTmuxSessionIfExists,
|
||||
processAlive: isProcessAlive,
|
||||
currentPid: process.pid,
|
||||
log,
|
||||
}
|
||||
}
|
||||
|
||||
export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise<number> {
|
||||
if (!deps.isInsideTmux()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const tmux = await deps.getTmuxPath()
|
||||
if (!tmux) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const candidateSessions = await deps.listCandidateSessions(tmux)
|
||||
let killedCount = 0
|
||||
|
||||
for (const sessionName of candidateSessions) {
|
||||
const pidMatch = sessionName.match(STALE_SESSION_PATTERN)
|
||||
if (!pidMatch) continue
|
||||
|
||||
const pid = Number.parseInt(pidMatch[1], 10)
|
||||
if (!Number.isFinite(pid)) continue
|
||||
if (pid === deps.currentPid) continue
|
||||
if (deps.processAlive(pid)) continue
|
||||
|
||||
deps.log("[sweepStaleOmoAgentSessions] killing stale session", { sessionName, deadPid: pid })
|
||||
const killed = await deps.killSession(sessionName)
|
||||
if (killed) {
|
||||
killedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return killedCount
|
||||
}
|
||||
|
||||
export async function sweepStaleOmoAgentSessions(): Promise<number> {
|
||||
const deps = await buildRuntimeDeps()
|
||||
return sweepStaleOmoAgentSessionsWith(deps)
|
||||
}
|
||||
Reference in New Issue
Block a user