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:
Zireael
2026-04-18 22:59:25 +02:00
452 changed files with 53406 additions and 41739 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/shared/ — 100+ Utility Files
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
+15 -15
View File
@@ -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)
+9 -5
View File
@@ -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
}
+5 -5
View File
@@ -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)
+8 -5
View File
@@ -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)
}
+2 -2
View File
@@ -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" },
],
})
+6 -6
View File
@@ -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,
})
+1 -1
View File
@@ -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(
+50
View File
@@ -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)
})
})
+18
View File
@@ -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))
+5
View File
@@ -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)
})
})
+10 -2
View File
@@ -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
View File
@@ -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
}
+89
View File
@@ -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)
})
})
})
})
+12
View File
@@ -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,
+2 -2
View File
@@ -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")
})
})
+27 -27
View File
@@ -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)
+1
View File
@@ -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",
}
+55 -6
View File
@@ -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)
})
})
+23 -7
View File
@@ -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)
+1 -1
View File
@@ -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"
* ]
* }
*/
+2 -1
View File
@@ -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",
}
+43 -43
View File
@@ -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"])
+1 -1
View File
@@ -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)
*/
+9 -9
View File
@@ -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,
+9 -9
View File
@@ -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",
})
+3 -3
View File
@@ -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")
})
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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", () => {
+29 -29
View File
@@ -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")
})
+87 -85
View File
@@ -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" },
],
},
};
+64 -64
View File
@@ -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: {},
})
+63
View File
@@ -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 }
}
+107
View File
@@ -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)
})
})
+84
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+25
View File
@@ -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
}
+6 -4
View File
@@ -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]),
]),
}
}
+57
View File
@@ -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
View File
@@ -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,
})
+43 -11
View File
@@ -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"))])
})
})
+18 -3
View File
@@ -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
}
}
+48 -14
View File
@@ -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]
})
}
+114
View File
@@ -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
}
}
+26
View File
@@ -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", () => {
+2 -2
View File
@@ -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"
}
+3 -1
View File
@@ -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"
+1
View File
@@ -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)
})
})
+31 -15
View File
@@ -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
}
+11 -6
View File
@@ -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)
}