Merge remote-tracking branch 'origin/dev' into opencode/mighty-wolf

This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-28 15:47:58 +09:00
141 changed files with 6764 additions and 932 deletions
+17 -7
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, normalizeAgentForPrompt, normalizeAgentForPromptKey } from "./agent-display-names"
import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, normalizeAgentForPrompt, normalizeAgentForPromptKey, stripAgentListSortPrefix } from "./agent-display-names"
describe("getAgentDisplayName", () => {
it("returns display name for lowercase config key (new format)", () => {
@@ -194,16 +194,26 @@ describe("getAgentConfigKey", () => {
})
describe("getAgentListDisplayName", () => {
it("applies invisible stable-sort prefixes to the core agent list", () => {
expect(getAgentListDisplayName("sisyphus")).toBe("\u200BSisyphus - Ultraworker")
expect(getAgentListDisplayName("hephaestus")).toBe("\u200B\u200BHephaestus - Deep Agent")
expect(getAgentListDisplayName("prometheus")).toBe("\u200B\u200B\u200BPrometheus - Plan Builder")
expect(getAgentListDisplayName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
it("returns the canonical display name for the core agent list", () => {
expect(getAgentListDisplayName("sisyphus")).toBe("Sisyphus - Ultraworker")
expect(getAgentListDisplayName("hephaestus")).toBe("Hephaestus - Deep Agent")
expect(getAgentListDisplayName("prometheus")).toBe("Prometheus - Plan Builder")
expect(getAgentListDisplayName("atlas")).toBe("Atlas - Plan Executor")
})
it("keeps non-core agents unprefixed for list display", () => {
it("keeps non-core agents unchanged for list display", () => {
expect(getAgentListDisplayName("oracle")).toBe("oracle")
})
it("is a thin alias for getAgentDisplayName", () => {
expect(getAgentListDisplayName("sisyphus")).toBe(getAgentDisplayName("sisyphus"))
})
})
describe("stripAgentListSortPrefix", () => {
it("strips legacy zero-width sort prefixes baked into v3.14.0v3.16.0 sessions", () => {
expect(stripAgentListSortPrefix("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
})
})
describe("normalizeAgentForPrompt", () => {
+10 -18
View File
@@ -26,13 +26,6 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
"council-member": "council-member",
}
const AGENT_LIST_SORT_PREFIXES: Record<string, string> = {
sisyphus: "\u200B",
hephaestus: "\u200B\u200B",
prometheus: "\u200B\u200B\u200B",
atlas: "\u200B\u200B\u200B\u200B",
}
const INVISIBLE_AGENT_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
export function stripInvisibleAgentCharacters(agentName: string): string {
@@ -43,13 +36,6 @@ export function stripAgentListSortPrefix(agentName: string): string {
return stripInvisibleAgentCharacters(agentName)
}
export function getAgentRuntimeName(configKey: string): string {
const displayName = getAgentDisplayName(configKey)
const prefix = AGENT_LIST_SORT_PREFIXES[configKey.toLowerCase()]
return prefix ? `${prefix}${displayName}` : displayName
}
/**
* Get display name for an agent config key.
* Uses case-insensitive lookup for backward compatibility.
@@ -59,22 +45,28 @@ export function getAgentDisplayName(configKey: string): string {
// Try exact match first
const exactMatch = AGENT_DISPLAY_NAMES[configKey]
if (exactMatch !== undefined) return exactMatch
// Fall back to case-insensitive search
const lowerKey = configKey.toLowerCase()
for (const [k, v] of Object.entries(AGENT_DISPLAY_NAMES)) {
if (k.toLowerCase() === lowerKey) return v
}
// Unknown agent: return original key
return configKey
}
/**
* Runtime-facing agent name used for OpenCode list ordering.
* Thin alias for `getAgentDisplayName` preserved for external imports.
*
* Earlier versions injected zero-width prefixes here to bias OpenCode's
* `agent.name` sort. Sort ordering is now enforced by
* `src/shared/agent-sort-shim.ts`, so this function emits the canonical
* display name verbatim. Kept exported because downstream modules still
* import this symbol; do not collapse the call sites without coordinating.
*/
export function getAgentListDisplayName(configKey: string): string {
return getAgentRuntimeName(configKey)
return getAgentDisplayName(configKey)
}
const REVERSE_DISPLAY_NAMES: Record<string, string> = Object.fromEntries(
+168
View File
@@ -0,0 +1,168 @@
/// <reference types="bun-types" />
import { beforeAll, describe, expect, test } from "bun:test"
import { installAgentSortShim } from "./agent-sort-shim"
describe("agent-sort-shim", () => {
beforeAll(() => {
installAgentSortShim()
})
describe("#given an array of all 4 core agent objects in random order", () => {
describe("#when toSorted with alphabetical compareFn", () => {
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
// given
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const input = [atlas, prometheus, hephaestus, sisyphus]
// when
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
})
})
})
describe("#given 4 core agents mixed with 2 non-core agent objects", () => {
describe("#when toSorted with alphabetical compareFn", () => {
test("#then core agents come first in canonical order followed by non-core agents alphabetically", () => {
// given
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const build = { name: "build" }
const plan = { name: "plan" }
const input = [atlas, build, prometheus, plan, hephaestus, sisyphus]
// when
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas, build, plan])
})
})
})
describe("#given an array with only one core agent and several non-core agent-like objects", () => {
describe("#when toSorted with case-sensitive string-comparison compareFn", () => {
test("#then activation predicate fails and result is ASCII-sensitive order with capital S before lowercase letters", () => {
// given
const oracle = { name: "oracle" }
const librarian = { name: "librarian" }
const sisyphus = { name: "Sisyphus - Ultraworker" }
const explore = { name: "explore" }
const input = [oracle, librarian, sisyphus, explore]
// when
const result = input.toSorted((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
)
// then
expect(result).toEqual([sisyphus, explore, librarian, oracle])
})
})
})
describe("#given a mixed-type array containing null, objects, a string, and a number", () => {
describe("#when toSorted with a string-coercing compareFn", () => {
test("#then activation predicate fails, shim does not throw, and result matches native semantics", () => {
// given
const sisyphusObj = { name: "Sisyphus - Ultraworker" }
const hephaestusObj = { name: "Hephaestus - Deep Agent" }
const input: unknown[] = [null, sisyphusObj, "string", 42, hephaestusObj]
const compare = (a: unknown, b: unknown): number => {
const sa = String(a)
const sb = String(b)
if (sa < sb) return -1
if (sa > sb) return 1
return 0
}
// when
const result = input.toSorted(compare)
// then
expect(result).toEqual([42, sisyphusObj, hephaestusObj, null, "string"])
})
})
})
describe("#given a plain string array", () => {
describe("#when toSorted with no compareFn", () => {
test("#then returns native alphabetical ordering untouched", () => {
// given
const input = ["zebra", "apple", "mango"]
// when
const result = input.toSorted()
// then
expect(result).toEqual(["apple", "mango", "zebra"])
})
})
})
describe("#given a number array", () => {
describe("#when sort with numeric compareFn (in-place)", () => {
test("#then mutates the array and returns the same reference in ascending order", () => {
// given
const input = [3, 1, 4, 1, 5, 9, 2, 6]
// when
const result = input.sort((a, b) => a - b)
// then
expect(result).toBe(input)
expect(input).toEqual([1, 1, 2, 3, 4, 5, 6, 9])
})
})
})
describe("#given agent objects with all 4 core display names in random order", () => {
describe("#when sort with alphabetical compareFn (in-place)", () => {
test("#then mutates the original array to canonical order", () => {
// given
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const input = [atlas, prometheus, hephaestus, sisyphus]
// when
const result = input.sort((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toBe(input)
expect(input).toEqual([sisyphus, hephaestus, prometheus, atlas])
})
})
})
describe("#given installAgentSortShim has been invoked multiple times", () => {
describe("#when toSorted is called on core agents after duplicate installs", () => {
test("#then result is canonical order with no double-wrapping side effects", () => {
// given
installAgentSortShim()
installAgentSortShim()
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const input = [atlas, prometheus, hephaestus, sisyphus]
// when
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
})
})
})
})
+116
View File
@@ -0,0 +1,116 @@
/**
* Agent sort shim.
*
* OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and
* sorts the agent list by `agent.name` via Remeda `sortBy(x => x.name, "asc")`
* at packages/opencode/src/agent/agent.ts. Without intervention, the four
* core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus,
* which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas
* order this project ships.
*
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
* U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap
* and column-truncation regressions in the TUI status bar (#3259, #3238).
*
* This shim is the narrowly-scoped alternative from PR #3267 with the Cubic
* P1 mitigations applied:
* 1. `isAgentArray` rejects any array element that is null, non-object, or
* lacks a string `name`, eliminating the throw-on-mixed-array failure
* mode that closed the original PR.
* 2. The activation predicate requires >= 2 elements whose `.name` is one
* of the four canonical core display names, so unrelated `.sort()` and
* `.toSorted()` calls (string arrays, number arrays, generic objects)
* execute native behavior unchanged.
*
* Remove this shim once OpenCode honors the agent `order` field
* (sst/opencode#19127).
*/
import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order"
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
const AGENT_RANK: ReadonlyMap<string, number> = new Map(
CANONICAL_CORE_AGENT_ORDER.map(
(configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1],
),
)
const UNRANKED = Number.MAX_SAFE_INTEGER
function extractAgentName(value: unknown): string {
if (value === null || typeof value !== "object") return ""
const candidate = value as { name?: unknown }
return typeof candidate.name === "string" ? candidate.name : ""
}
function isAgentArray(arr: ReadonlyArray<unknown>): boolean {
if (arr.length < 2) return false
let rankedCount = 0
for (const element of arr) {
if (element === null || typeof element !== "object") return false
const name = (element as { name?: unknown }).name
if (typeof name !== "string") return false
if (AGENT_RANK.has(name)) rankedCount++
}
return rankedCount >= 2
}
function agentComparator(
a: unknown,
b: unknown,
fallback: ((a: unknown, b: unknown) => number) | undefined,
): number {
const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED
const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED
if (aRank !== bRank) return aRank - bRank
if (fallback) return fallback(a, b)
return 0
}
let installed = false
export function installAgentSortShim(): void {
if (installed) return
const originalToSorted = Array.prototype.toSorted
const originalSort = Array.prototype.sort
function patchedToSorted(
this: unknown[],
compareFn?: (a: unknown, b: unknown) => number,
): unknown[] {
if (isAgentArray(this)) {
return originalToSorted.call(this, (a, b) => agentComparator(a, b, compareFn))
}
return originalToSorted.call(this, compareFn)
}
function patchedSort(
this: unknown[],
compareFn?: (a: unknown, b: unknown) => number,
): unknown[] {
if (isAgentArray(this)) {
return originalSort.call(this, (a, b) => agentComparator(a, b, compareFn))
}
return originalSort.call(this, compareFn)
}
Object.defineProperty(Array.prototype, "toSorted", {
value: patchedToSorted,
configurable: true,
writable: true,
enumerable: false,
})
Object.defineProperty(Array.prototype, "sort", {
value: patchedSort,
configurable: true,
writable: true,
enumerable: false,
})
installed = true
}
+7 -7
View File
@@ -113,9 +113,9 @@ describe("resolveVariantForModel", () => {
})
test("returns correct variant for openai provider (hephaestus agent)", () => {
// #given hephaestus has openai/gpt-5.4 with variant "medium" in its chain
// #given hephaestus has openai/gpt-5.5 with variant "medium" in its chain
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.4" }
const model = { providerID: "openai", modelID: "gpt-5.5" }
// #when
const variant = resolveVariantForModel(config, "hephaestus", model)
@@ -124,10 +124,10 @@ describe("resolveVariantForModel", () => {
expect(variant).toBe("medium")
})
test("returns medium for openai/gpt-5.4 in sisyphus chain", () => {
// #given openai/gpt-5.4 is now in sisyphus fallback chain with variant medium
test("returns medium for openai/gpt-5.5 in sisyphus chain", () => {
// #given openai/gpt-5.5 is now in sisyphus fallback chain with variant medium
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.4" }
const model = { providerID: "openai", modelID: "gpt-5.5" }
// when
const variant = resolveVariantForModel(config, "sisyphus", model)
@@ -179,7 +179,7 @@ describe("resolveVariantForModel", () => {
"custom-agent": { category: "ultrabrain" },
},
} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.4" }
const model = { providerID: "openai", modelID: "gpt-5.5" }
// when
const variant = resolveVariantForModel(config, "custom-agent", model)
@@ -191,7 +191,7 @@ describe("resolveVariantForModel", () => {
test("returns correct variant for oracle agent with openai", () => {
// given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.4" }
const model = { providerID: "openai", modelID: "gpt-5.5" }
// when
const variant = resolveVariantForModel(config, "oracle", model)
+56 -2
View File
@@ -1,8 +1,62 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { resolveFileReferencesInText } from "./file-reference-resolver"
import { join, resolve } from "node:path"
import { resolveFilePath, resolveFileReferencesInText } from "./file-reference-resolver"
describe("resolveFilePath", () => {
const cwd = "/skills/gsd"
test("expands bare environment variables before resolving absolute paths", () => {
//#given
const homeDir = process.env.HOME
if (!homeDir) {
throw new Error("HOME must be set for file reference resolver tests")
}
//#when
const resolved = resolveFilePath("$HOME/foo.md", cwd)
//#then
expect(resolved).toBe(resolve(homeDir, "foo.md"))
})
test("expands braced environment variables before resolving absolute paths", () => {
//#given
const homeDir = process.env.HOME
if (!homeDir) {
throw new Error("HOME must be set for file reference resolver tests")
}
//#when
const resolved = resolveFilePath("${HOME}/foo.md", cwd)
//#then
expect(resolved).toBe(resolve(homeDir, "foo.md"))
})
test("keeps absolute paths absolute", () => {
//#given
const absolutePath = "/abs/path.md"
//#when
const resolved = resolveFilePath(absolutePath, cwd)
//#then
expect(resolved).toBe(resolve(absolutePath))
})
test("resolves relative paths from cwd", () => {
//#given
const relativePath = "relative/path.md"
//#when
const resolved = resolveFilePath(relativePath, cwd)
//#then
expect(resolved).toBe(resolve(cwd, relativePath))
})
})
describe("resolveFileReferencesInText", () => {
const fixtureRoot = join(tmpdir(), `file-reference-resolver-${Date.now()}`)
+12 -4
View File
@@ -30,12 +30,20 @@ function findFileReferences(text: string): FileMatch[] {
return matches
}
function resolveFilePath(filePath: string, cwd: string): string {
if (isAbsolute(filePath)) {
return resolve(filePath)
export function resolveFilePath(filePath: string, cwd: string): string {
const expanded = filePath.replace(/\$\{(\w+)\}|\$(\w+)/g, (match, braced: string | undefined, bare: string | undefined) => {
const variableName = braced ?? bare
if (!variableName) {
return match
}
return process.env[variableName] ?? match
})
if (isAbsolute(expanded)) {
return resolve(expanded)
}
return resolve(cwd, filePath)
return resolve(cwd, expanded)
}
function readFileContent(resolvedPath: string): string {
@@ -35,6 +35,31 @@ describe("migrateLegacyConfigFile", () => {
})
})
describe("#given a legacy config sidecar exists", () => {
describe("#when migrating the config file", () => {
it("#then copies applied migration history to the canonical sidecar", () => {
const legacyPath = join(testDir, "oh-my-opencode.json")
const legacySidecarPath = `${legacyPath}.migrations.json`
const canonicalSidecarPath = join(testDir, "oh-my-openagent.json.migrations.json")
writeFileSync(legacyPath, '{ "agents": { "oracle": { "model": "anthropic/claude-opus-4-6" } } }')
writeFileSync(
legacySidecarPath,
JSON.stringify({
appliedMigrations: [
"model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7",
],
}),
)
const result = migrateLegacyConfigFile(legacyPath)
expect(result).toBe(true)
expect(existsSync(canonicalSidecarPath)).toBe(true)
expect(readFileSync(canonicalSidecarPath, "utf-8")).toBe(readFileSync(legacySidecarPath, "utf-8"))
})
})
})
describe("#given oh-my-opencode.json exists but oh-my-openagent.json does not", () => {
describe("#when migrating the config file", () => {
it("#then copies to oh-my-openagent.json", () => {
+28
View File
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs"
import { join, dirname, basename } from "node:path"
import { log } from "./logger"
import { getSidecarPath } from "./migration/migrations-sidecar"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity"
import { writeFileAtomically } from "./write-file-atomically"
@@ -42,6 +43,31 @@ function archiveLegacyConfigFile(legacyPath: string): boolean {
}
}
function migrateLegacySidecarFile(legacyPath: string, canonicalPath: string): boolean {
const legacySidecarPath = getSidecarPath(legacyPath)
if (!existsSync(legacySidecarPath)) return true
const canonicalSidecarPath = getSidecarPath(canonicalPath)
if (existsSync(canonicalSidecarPath)) return true
try {
const content = readFileSync(legacySidecarPath, "utf-8")
writeFileAtomically(canonicalSidecarPath, content)
log("[migrateLegacyConfigFile] Migrated legacy migration sidecar to canonical path", {
from: legacySidecarPath,
to: canonicalSidecarPath,
})
return true
} catch (error) {
log("[migrateLegacyConfigFile] Failed to migrate legacy migration sidecar", {
legacySidecarPath,
canonicalSidecarPath,
error,
})
return false
}
}
export function migrateLegacyConfigFile(legacyPath: string): boolean {
if (!existsSync(legacyPath)) return false
if (!basename(legacyPath).startsWith(LEGACY_CONFIG_BASENAME)) return false
@@ -52,10 +78,12 @@ export function migrateLegacyConfigFile(legacyPath: string): boolean {
try {
const content = readFileSync(legacyPath, "utf-8")
writeFileAtomically(canonicalPath, content)
const migratedSidecar = migrateLegacySidecarFile(legacyPath, canonicalPath)
const archivedLegacyConfig = archiveLegacyConfigFile(legacyPath)
log("[migrateLegacyConfigFile] Migrated legacy config to canonical path", {
from: legacyPath,
to: canonicalPath,
migratedSidecar,
archivedLegacyConfig,
})
return true
+8 -8
View File
@@ -39,7 +39,7 @@ describe("migrateAgentNames", () => {
test("preserves current agent names unchanged", () => {
// given: Config with current agent names
const agents = {
oracle: { model: "openai/gpt-5.4" },
oracle: { model: "openai/gpt-5.5-preview" },
librarian: { model: "google/gemini-3-flash" },
explore: { model: "opencode/gpt-5-nano" },
}
@@ -49,7 +49,7 @@ describe("migrateAgentNames", () => {
// then: Current names should remain unchanged
expect(changed).toBe(false)
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.4" })
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.5-preview" })
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
expect(migrated["explore"]).toEqual({ model: "opencode/gpt-5-nano" })
})
@@ -620,7 +620,7 @@ describe("migrateModelVersions", () => {
test("leaves unknown model strings untouched", () => {
// given: Agent config with unknown model
const agents = {
oracle: { model: "openai/gpt-5.4", temperature: 0.5 },
oracle: { model: "openai/gpt-5.5-preview", temperature: 0.5 },
}
// when: Migrate model versions
@@ -629,7 +629,7 @@ describe("migrateModelVersions", () => {
// then: Config should remain unchanged
expect(changed).toBe(false)
const oracle = migrated["oracle"] as Record<string, unknown>
expect(oracle.model).toBe("openai/gpt-5.4")
expect(oracle.model).toBe("openai/gpt-5.5-preview")
})
test("handles agent config with no model field", () => {
@@ -665,7 +665,7 @@ describe("migrateModelVersions", () => {
const agents = {
sisyphus: { model: "openai/gpt-5.4-codex" },
prometheus: { model: "anthropic/claude-opus-4-5" },
oracle: { model: "openai/gpt-5.4" },
oracle: { model: "openai/gpt-5.5-preview" },
}
// when: Migrate model versions
@@ -675,7 +675,7 @@ describe("migrateModelVersions", () => {
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-7")
expect((migrated["oracle"] as Record<string, unknown>).model).toBe("openai/gpt-5.4")
expect((migrated["oracle"] as Record<string, unknown>).model).toBe("openai/gpt-5.5-preview")
})
test("handles empty object", () => {
@@ -1083,7 +1083,7 @@ describe("migrateConfigFile with backup", () => {
const rawConfig: Record<string, unknown> = {
agents: {
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
oracle: { model: "openai/gpt-5.4" },
oracle: { model: "openai/gpt-5.5-preview" },
"my-custom-agent": { model: "google/gemini-3.1-pro" },
},
}
@@ -1099,7 +1099,7 @@ describe("migrateConfigFile with backup", () => {
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
expect(agents.oracle.model).toBe("openai/gpt-5.4")
expect(agents.oracle.model).toBe("openai/gpt-5.5-preview")
expect(agents["my-custom-agent"].model).toBe("google/gemini-3.1-pro")
})
@@ -118,6 +118,37 @@ describe("migrateConfigFile sidecar write ordering", () => {
)
expect(statSync(getSidecarPath(configPath)).isDirectory()).toBe(true)
})
test("treats top-level appliedMigrations as migration history and does not reapply the model update", () => {
// given
const workdir = createWorkdir()
const configPath = join(workdir, "oh-my-openagent.json")
const rawConfig: Record<string, unknown> = {
agents: {
oracle: { model: "anthropic/claude-opus-4-6" },
},
appliedMigrations: ["model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7"],
}
writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n")
// when
const needsWrite = migrateConfigFile(configPath, rawConfig)
// then
expect(needsWrite).toBe(true)
expect(rawConfig.appliedMigrations).toBeUndefined()
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe(
"anthropic/claude-opus-4-6",
)
const sidecar = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) as {
appliedMigrations: string[]
}
expect(sidecar.appliedMigrations).toEqual([
"model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7",
])
})
})
describe("migrateConfigFile backup skipping", () => {
+9 -3
View File
@@ -22,13 +22,18 @@ export function migrateConfigFile(
// that still carry `_migrations` working without a forced reset.
const sidecarMigrations = readAppliedMigrations(configPath)
const inConfigMigrations = Array.isArray(copy._migrations)
? new Set(copy._migrations as string[])
? new Set(copy._migrations.filter((migration): migration is string => typeof migration === "string"))
: new Set<string>()
const inlineAppliedMigrations = Array.isArray(copy.appliedMigrations)
? new Set(copy.appliedMigrations.filter((migration): migration is string => typeof migration === "string"))
: new Set<string>()
const existingMigrations = new Set<string>([
...sidecarMigrations,
...inConfigMigrations,
...inlineAppliedMigrations,
])
const hadLegacyInConfigMigrations = inConfigMigrations.size > 0
const hadInlineAppliedMigrations = inlineAppliedMigrations.size > 0
const allNewMigrations: string[] = []
if (copy.agents && typeof copy.agents === "object") {
@@ -78,12 +83,13 @@ export function migrateConfigFile(
...existingMigrations,
...newMigrationsToRecord,
])
const shouldWriteSidecar = newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations
const shouldWriteSidecar = newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations || hadInlineAppliedMigrations
if (newMigrationsToRecord.length > 0) {
needsWrite = true
}
if (hadLegacyInConfigMigrations) {
if (hadLegacyInConfigMigrations || hadInlineAppliedMigrations) {
// Migrating state out of the config body is itself a config write.
delete copy.appliedMigrations
needsWrite = true
}
if (shouldWriteSidecar) {
+1
View File
@@ -10,6 +10,7 @@ export const MODEL_VERSION_MAP: Record<string, string> = {
"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",
"openai/gpt-5.4": "openai/gpt-5.5",
}
function migrationKey(oldModel: string, newModel: string): string {
+55
View File
@@ -59,6 +59,12 @@ describe("getModelCapabilities", () => {
output: 128_000,
},
},
"minimax-m2.7": {
id: "minimax-m2.7",
family: "minimax",
reasoning: true,
temperature: true,
},
},
}
@@ -325,6 +331,55 @@ describe("getModelCapabilities", () => {
})
})
test("marks MiniMax M2.7 as not supporting thinking despite snapshot reasoning", () => {
// given
const modelID = "minimax-m2.7"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(false)
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("marks non-thinking Kimi K2.6 as not supporting thinking", () => {
// given
const modelID = "kimi-k2.6"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(false)
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("keeps thinking-flavored Kimi K2.6 models as supporting thinking", () => {
// given
const modelID = "kimi-k2.6-thinking"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(true)
expect(result.family).toBe("kimi-thinking")
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("detects prefixed o-series model IDs through the heuristic fallback", () => {
const result = getModelCapabilities({
providerID: "azure-openai",
@@ -17,4 +17,20 @@ export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSn
output: 128000,
},
},
"gpt-5.5": {
id: "gpt-5.5",
family: "gpt",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image", "pdf"],
output: ["text"],
},
limit: {
context: 400000,
input: 272000,
output: 128000,
},
},
}
@@ -56,6 +56,28 @@ describe("model-capability-aliases", () => {
})
})
test("normalizes Kimi for Coding k2pb aliases to the snapshot ID", () => {
const result = resolveModelIDAlias("kimi-for-coding/k2pb")
expect(result).toEqual({
requestedModelID: "kimi-for-coding/k2pb",
canonicalModelID: "k2p5",
source: "exact-alias",
ruleID: "kimi-k2pb-alias",
})
})
test("normalizes GitHub Copilot dotted Claude Opus aliases to the snapshot ID", () => {
const result = resolveModelIDAlias("github-copilot/claude-opus-4.7")
expect(result).toEqual({
requestedModelID: "github-copilot/claude-opus-4.7",
canonicalModelID: "claude-opus-4-7",
source: "exact-alias",
ruleID: "claude-opus-dotted-version-alias",
})
})
test("does not resolve prototype keys as aliases", () => {
const result = resolveModelIDAlias("constructor")
+12
View File
@@ -32,6 +32,18 @@ const EXACT_ALIAS_RULES: ReadonlyArray<ExactAliasRule> = [
canonicalModelID: "gemini-3-pro-preview",
rationale: "Legacy Gemini 3 tier suffixes still need to land on the canonical preview model.",
},
{
aliasModelID: "k2pb",
ruleID: "kimi-k2pb-alias",
canonicalModelID: "k2p5",
rationale: "Kimi for Coding exposes k2pb while the bundled capabilities snapshot uses the canonical k2p5 ID.",
},
{
aliasModelID: "claude-opus-4.7",
ruleID: "claude-opus-dotted-version-alias",
canonicalModelID: "claude-opus-4-7",
rationale: "GitHub Copilot exposes Claude Opus 4.7 with dotted version syntax while the snapshot uses dashed syntax.",
},
]
const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
@@ -20,7 +20,7 @@ describe("model-capability-guardrails", () => {
expect(modelIDs).toEqual([...modelIDs].sort())
expect(new Set(modelIDs).size).toBe(modelIDs.length)
expect(modelIDs).toContain("claude-opus-4-7")
expect(modelIDs).toContain("gpt-5.4")
expect(modelIDs).toContain("gpt-5.5")
expect(modelIDs).toContain("kimi-k2.5")
})
@@ -44,10 +44,18 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
includes: ["gemini"],
variants: ["low", "medium", "high"],
},
{
family: "kimi-thinking",
includes: ["kimi-thinking", "k2-thinking", "k2-think"],
pattern: /(?:kimi|k2).*-(?:thinking|think)/,
variants: ["low", "medium", "high"],
supportsThinking: true,
},
{
family: "kimi",
includes: ["kimi", "k2"],
variants: ["low", "medium", "high"],
supportsThinking: false,
},
{
family: "glm",
@@ -58,6 +66,7 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
family: "minimax",
includes: ["minimax"],
variants: ["low", "medium", "high"],
supportsThinking: false,
},
{
family: "deepseek",
+29 -29
View File
@@ -7,23 +7,23 @@ import {
} from "./model-requirements"
describe("AGENT_MODEL_REQUIREMENTS", () => {
test("oracle has valid fallbackChain with gpt-5.4 as primary", () => {
test("oracle has valid fallbackChain with gpt-5.5 as primary", () => {
// given - oracle agent requirement
const oracle = AGENT_MODEL_REQUIREMENTS["oracle"]
// when - accessing oracle requirement
// then - fallbackChain exists with gpt-5.4 as first entry
// then - fallbackChain exists with gpt-5.5 as first entry
expect(oracle).toBeDefined()
expect(oracle.fallbackChain).toBeArray()
expect(oracle.fallbackChain.length).toBeGreaterThan(0)
const primary = oracle.fallbackChain[0]
expect(primary.providers).toContain("openai")
expect(primary.model).toBe("gpt-5.4")
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("high")
})
test("sisyphus has claude-opus-4-7 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.5 medium fallbacks", () => {
// #given - sisyphus agent requirement
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
@@ -50,10 +50,10 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const fourth = sisyphus.fallbackChain[3]
expect(fourth.model).toBe("kimi-k2.5")
const fifth = sisyphus.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.4")
expect(fifth.variant).toBe("medium")
const fifth = sisyphus.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.5")
expect(fifth.variant).toBe("medium")
const sixth = sisyphus.fallbackChain[5]
expect(sixth.providers[0]).toBe("zai-coding-plan")
@@ -125,19 +125,19 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(fifth.model).toBe("gpt-5.4-nano")
})
test("multimodal-looker has valid fallbackChain with gpt-5.4 as primary", () => {
test("multimodal-looker has valid fallbackChain with gpt-5.5 as primary", () => {
// given - multimodal-looker agent requirement
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// when - accessing multimodal-looker requirement
// then - fallbackChain: gpt-5.4 -> opencode-go/kimi-k2.5 -> glm-4.6v -> gpt-5-nano
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.5 -> glm-4.6v -> gpt-5-nano
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain).toHaveLength(4)
const primary = multimodalLooker.fallbackChain[0]
expect(primary.providers).toEqual(["openai", "opencode", "vercel"])
expect(primary.model).toBe("gpt-5.4")
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("medium")
const secondary = multimodalLooker.fallbackChain[1]
@@ -186,23 +186,23 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const openAiFallback = metis.fallbackChain.find((entry) => entry.providers.includes("openai"))
expect(openAiFallback).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "high",
})
})
test("momus has valid fallbackChain with gpt-5.4 as primary", () => {
test("momus has valid fallbackChain with gpt-5.5 as primary", () => {
// given - momus agent requirement
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
// when - accessing Momus requirement
// then - fallbackChain exists with gpt-5.4 as first entry, variant xhigh
// then - fallbackChain exists with gpt-5.5 as first entry, variant xhigh
expect(momus).toBeDefined()
expect(momus.fallbackChain).toBeArray()
expect(momus.fallbackChain.length).toBeGreaterThan(0)
const primary = momus.fallbackChain[0]
expect(primary.model).toBe("gpt-5.4")
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("xhigh")
expect(primary.providers[0]).toBe("openai")
})
@@ -228,7 +228,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const tertiary = atlas.fallbackChain[2]
expect(tertiary).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
})
@@ -250,7 +250,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
// then
expect(openAiFallback).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
})
expect(openAiFallbackIndex).toBeGreaterThan(-1)
@@ -307,35 +307,35 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
})
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
test("ultrabrain has valid fallbackChain with gpt-5.4 as primary", () => {
test("ultrabrain has valid fallbackChain with gpt-5.5 as primary", () => {
// given - ultrabrain category requirement
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
// when - accessing ultrabrain requirement
// then - fallbackChain exists with gpt-5.4 as first entry
// then - fallbackChain exists with gpt-5.5 as first entry
expect(ultrabrain).toBeDefined()
expect(ultrabrain.fallbackChain).toBeArray()
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
const primary = ultrabrain.fallbackChain[0]
expect(primary.variant).toBe("xhigh")
expect(primary.model).toBe("gpt-5.4")
expect(primary.model).toBe("gpt-5.5")
expect(primary.providers[0]).toBe("openai")
})
test("deep has valid fallbackChain with gpt-5.4 as primary", () => {
test("deep has valid fallbackChain with gpt-5.5 as primary", () => {
// given - deep category requirement
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
// when - accessing deep requirement
// then - fallbackChain exists with gpt-5.4 as first entry, medium variant
// then - fallbackChain exists with gpt-5.5 as first entry, medium variant
expect(deep).toBeDefined()
expect(deep.fallbackChain).toBeArray()
expect(deep.fallbackChain.length).toBeGreaterThan(0)
const primary = deep.fallbackChain[0]
expect(primary.variant).toBe("medium")
expect(primary.model).toBe("gpt-5.4")
expect(primary.model).toBe("gpt-5.5")
expect(primary.providers).toContain("openai")
expect(primary.providers).toContain("github-copilot")
})
@@ -406,12 +406,12 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-high has claude-opus-4-7 as primary and gpt-5.4 as secondary", () => {
test("unspecified-high has claude-opus-4-7 as primary and gpt-5.5 as secondary", () => {
// #given - unspecified-high category requirement
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
// #when - accessing unspecified-high requirement
// #then - claude-opus-4-7 is first and gpt-5.4 is second
// #then - claude-opus-4-7 is first and gpt-5.5 is second
expect(unspecifiedHigh).toBeDefined()
expect(unspecifiedHigh.fallbackChain).toBeArray()
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1)
@@ -422,7 +422,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
const secondary = unspecifiedHigh.fallbackChain[1]
expect(secondary.model).toBe("gpt-5.4")
expect(secondary.model).toBe("gpt-5.5")
expect(secondary.variant).toBe("high")
expect(secondary.providers).toEqual(["openai", "github-copilot", "opencode", "vercel"])
})
@@ -539,7 +539,7 @@ describe("ModelRequirement type", () => {
const requirement: ModelRequirement = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["openai", "github-copilot"], model: "gpt-5.4", variant: "high" },
{ providers: ["openai", "github-copilot"], model: "gpt-5.5", variant: "high" },
],
}
@@ -548,7 +548,7 @@ describe("ModelRequirement type", () => {
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain).toHaveLength(2)
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-7")
expect(requirement.fallbackChain[1].model).toBe("gpt-5.4")
expect(requirement.fallbackChain[1].model).toBe("gpt-5.5")
})
test("ModelRequirement variant is optional", () => {
@@ -597,7 +597,7 @@ describe("ModelRequirement type", () => {
})
describe("requiresModel field in categories", () => {
test("deep category no longer has requiresModel (gpt-5.4 is widely available)", () => {
test("deep category no longer has requiresModel (gpt-5.5 is widely available)", () => {
// given
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
+13 -13
View File
@@ -39,7 +39,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
],
model: "kimi-k2.5",
},
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.4", variant: "medium" },
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{ providers: ["opencode"], model: "big-pickle" },
],
@@ -49,7 +49,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
},
],
@@ -59,7 +59,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "high",
},
{
@@ -95,7 +95,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4", variant: "medium" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", 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" },
@@ -110,7 +110,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
@@ -129,7 +129,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
@@ -140,7 +140,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "xhigh",
},
{
@@ -162,7 +162,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
@@ -174,7 +174,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
@@ -205,7 +205,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{
providers: ["openai", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "xhigh",
},
{
@@ -225,7 +225,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "medium",
},
{
@@ -252,7 +252,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.4" },
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5" },
],
requiresModel: "gemini-3.1-pro",
},
@@ -302,7 +302,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4",
model: "gpt-5.5",
variant: "high",
},
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { getModelCapabilities } from "./model-capabilities"
import { resolveCompatibleModelSettings } from "./model-settings-compatibility"
describe("resolveCompatibleModelSettings", () => {
@@ -467,6 +468,48 @@ describe("resolveCompatibleModelSettings", () => {
])
})
test("drops thinking for MiniMax M2.7 capabilities resolved from heuristics", () => {
// given
const capabilities = getModelCapabilities({
providerID: "volcengine",
modelID: "minimax-m2.7",
})
// when
const result = resolveCompatibleModelSettings({
providerID: "volcengine",
modelID: "minimax-m2.7",
desired: { thinking: { type: "enabled", budgetTokens: 4096 } },
capabilities,
})
// then
expect(result.thinking).toBeUndefined()
expect(result.changes[0]?.field).toBe("thinking")
expect(result.changes[0]?.reason).toBe("unsupported-by-model-metadata")
})
test("drops thinking for non-thinking Kimi K2.6 capabilities resolved from heuristics", () => {
// given
const capabilities = getModelCapabilities({
providerID: "volcengine",
modelID: "kimi-k2.6",
})
// when
const result = resolveCompatibleModelSettings({
providerID: "volcengine",
modelID: "kimi-k2.6",
desired: { thinking: { type: "enabled", budgetTokens: 4096 } },
capabilities,
})
// then
expect(result.thinking).toBeUndefined()
expect(result.changes[0]?.field).toBe("thinking")
expect(result.changes[0]?.reason).toBe("unsupported-by-model-metadata")
})
test("clamps maxTokens to the model output limit", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
+182 -9
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -37,9 +37,7 @@ describe("getPostHogActivityCaptureState", () => {
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
hourUTC: "2026-04-11T10",
captureDaily: true,
captureHourly: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
@@ -60,9 +58,7 @@ describe("getPostHogActivityCaptureState", () => {
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
hourUTC: "2026-04-11T10",
captureDaily: true,
captureHourly: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
@@ -83,9 +79,7 @@ describe("getPostHogActivityCaptureState", () => {
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
hourUTC: "2026-04-11T10",
captureDaily: true,
captureHourly: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
@@ -112,11 +106,190 @@ describe("getPostHogActivityCaptureState", () => {
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
hourUTC: "2026-04-11T10",
captureDaily: false,
captureHourly: false,
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("reads legacy hourly state without crashing", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
writeFileSync(
join(cachePath, "posthog-activity.json"),
`${JSON.stringify({
lastActiveHourUTC: "2026-04-11T10",
})}\n`,
)
process.env.XDG_DATA_HOME = dataHomePath
const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule()
// when
const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
captureDaily: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("preserves lastPluginLoadedDayUTC when writing lastActiveDayUTC", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
writeFileSync(
join(cachePath, "posthog-activity.json"),
`${JSON.stringify({
lastActiveDayUTC: "2026-04-10",
lastPluginLoadedDayUTC: "2026-04-11",
})}\n`,
)
process.env.XDG_DATA_HOME = dataHomePath
const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule()
// when
getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
const persistedState = JSON.parse(
readFileSync(join(cachePath, "posthog-activity.json"), "utf-8"),
)
expect(persistedState).toEqual({
lastActiveDayUTC: "2026-04-11",
lastPluginLoadedDayUTC: "2026-04-11",
})
rmSync(dataHomePath, { recursive: true, force: true })
})
})
describe("getPluginLoadedCaptureState", () => {
it("returns capturePluginLoaded=true when activity file does not exist", async () => {
// given
const dataHomePath = createDataHomePath()
process.env.XDG_DATA_HOME = dataHomePath
const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule()
// when
const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
capturePluginLoaded: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("returns capturePluginLoaded=false when lastPluginLoadedDayUTC matches today", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
writeFileSync(
join(cachePath, "posthog-activity.json"),
`${JSON.stringify({
lastPluginLoadedDayUTC: "2026-04-11",
})}\n`,
)
process.env.XDG_DATA_HOME = dataHomePath
const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule()
// when
const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
capturePluginLoaded: false,
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("returns capturePluginLoaded=true when lastPluginLoadedDayUTC is from a previous day", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
writeFileSync(
join(cachePath, "posthog-activity.json"),
`${JSON.stringify({
lastPluginLoadedDayUTC: "2026-04-10",
})}\n`,
)
process.env.XDG_DATA_HOME = dataHomePath
const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule()
// when
const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
expect(result).toEqual({
dayUTC: "2026-04-11",
capturePluginLoaded: true,
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("preserves lastActiveDayUTC when writing lastPluginLoadedDayUTC", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
writeFileSync(
join(cachePath, "posthog-activity.json"),
`${JSON.stringify({
lastActiveDayUTC: "2026-04-11",
lastPluginLoadedDayUTC: "2026-04-10",
})}\n`,
)
process.env.XDG_DATA_HOME = dataHomePath
const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule()
// when
getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
const persistedState = JSON.parse(
readFileSync(join(cachePath, "posthog-activity.json"), "utf-8"),
)
expect(persistedState).toEqual({
lastActiveDayUTC: "2026-04-11",
lastPluginLoadedDayUTC: "2026-04-11",
})
rmSync(dataHomePath, { recursive: true, force: true })
})
it("does not rewrite state when lastPluginLoadedDayUTC matches today", async () => {
// given
const dataHomePath = createDataHomePath()
const cachePath = join(dataHomePath, "oh-my-opencode")
mkdirSync(cachePath, { recursive: true })
const initialPayload = `${JSON.stringify({
lastActiveDayUTC: "2026-04-10",
lastPluginLoadedDayUTC: "2026-04-11",
})}\n`
writeFileSync(join(cachePath, "posthog-activity.json"), initialPayload)
process.env.XDG_DATA_HOME = dataHomePath
const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule()
// when
getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z"))
// then
const persistedPayload = readFileSync(join(cachePath, "posthog-activity.json"), "utf-8")
expect(persistedPayload).toBe(initialPayload)
rmSync(dataHomePath, { recursive: true, force: true })
})
})
+28 -14
View File
@@ -8,14 +8,17 @@ import { writeFileAtomically } from "./write-file-atomically"
type PostHogActivityState = {
lastActiveDayUTC?: string
lastActiveHourUTC?: string
lastPluginLoadedDayUTC?: string
}
type PostHogActivityCaptureState = {
dayUTC: string
hourUTC: string
captureDaily: boolean
captureHourly: boolean
}
type PluginLoadedCaptureState = {
dayUTC: string
capturePluginLoaded: boolean
}
const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json"
@@ -28,10 +31,6 @@ function getUtcDayString(date: Date): string {
return date.toISOString().slice(0, 10)
}
function getUtcHourString(date: Date): string {
return date.toISOString().slice(0, 13)
}
function isPostHogActivityState(value: unknown): value is PostHogActivityState {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
@@ -75,22 +74,37 @@ function writePostHogActivityState(nextState: PostHogActivityState): void {
export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogActivityCaptureState {
const state = readPostHogActivityState()
const dayUTC = getUtcDayString(now)
const hourUTC = getUtcHourString(now)
const captureDaily = state.lastActiveDayUTC !== dayUTC
const captureHourly = state.lastActiveHourUTC !== hourUTC
if (captureDaily || captureHourly) {
if (captureDaily) {
writePostHogActivityState({
lastActiveDayUTC: captureDaily ? dayUTC : state.lastActiveDayUTC,
lastActiveHourUTC: captureHourly ? hourUTC : state.lastActiveHourUTC,
...state,
lastActiveDayUTC: dayUTC,
})
}
return {
dayUTC,
hourUTC,
captureDaily,
captureHourly,
}
}
export function getPluginLoadedCaptureState(now: Date = new Date()): PluginLoadedCaptureState {
const state = readPostHogActivityState()
const dayUTC = getUtcDayString(now)
const capturePluginLoaded = state.lastPluginLoadedDayUTC !== dayUTC
if (capturePluginLoaded) {
writePostHogActivityState({
...state,
lastPluginLoadedDayUTC: dayUTC,
})
}
return {
dayUTC,
capturePluginLoaded,
}
}
+154 -8
View File
@@ -1,23 +1,54 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
type CapturedPostHogMessage = {
distinctId: string
event: string
properties?: Record<string, unknown>
}
async function importPostHogModule(): Promise<typeof import("./posthog")> {
return import(`./posthog?test=${Date.now()}-${Math.random()}`)
}
function enableTelemetryEnv(): void {
process.env.OMO_DISABLE_POSTHOG = "0"
process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1"
process.env.POSTHOG_API_KEY = "test-api-key"
}
function clearTelemetryEnv(): void {
delete process.env.OMO_DISABLE_POSTHOG
delete process.env.OMO_SEND_ANONYMOUS_TELEMETRY
delete process.env.POSTHOG_API_KEY
delete process.env.POSTHOG_HOST
}
function mockPostHogNode(capturedMessages: CapturedPostHogMessage[]): void {
mock.module("posthog-node", () => ({
PostHog: class {
capture(message: CapturedPostHogMessage): void {
capturedMessages.push(message)
}
captureException(): void {}
async shutdown(): Promise<void> {}
},
}))
}
describe("posthog client creation", () => {
beforeEach(() => {
mock.restore()
clearTelemetryEnv()
})
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
clearTelemetryEnv()
})
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"
enableTelemetryEnv()
mock.module("posthog-node", () => ({
PostHog: class {
@@ -54,4 +85,119 @@ describe("posthog client creation", () => {
expect(() => pluginPostHog.trackActive("plugin", "plugin_loaded")).not.toThrow()
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
})
it("creates a plugin client when os.cpus 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("os", () => ({
default: {
arch: () => "x64",
cpus: () => {
throw new Error("Failed to get CPU information")
},
hostname: () => "test-host",
platform: () => "linux",
release: () => "6.8.0-arch1-1",
totalmem: () => 8 * 1024 * 1024 * 1024,
type: () => "Linux",
},
}))
mock.module("posthog-node", () => ({
PostHog: class {
capture() {}
captureException() {}
async shutdown() {}
},
}))
const { createPluginPostHog } = await importPostHogModule()
// when
const pluginPostHog = createPluginPostHog()
// then
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()
})
})
describe("posthog trackActive emission contract", () => {
let resetActivityStateProvider: (() => void) | null = null
beforeEach(() => {
mock.restore()
clearTelemetryEnv()
})
afterEach(() => {
resetActivityStateProvider?.()
resetActivityStateProvider = null
mock.restore()
clearTelemetryEnv()
})
it("emits exactly one omo_daily_active and never omo_hourly_active when captureDaily is true", async () => {
// given
enableTelemetryEnv()
const captured: CapturedPostHogMessage[] = []
mockPostHogNode(captured)
const posthogModule = await importPostHogModule()
posthogModule.__setActivityStateProviderForTesting(() => ({
dayUTC: "2026-04-18",
captureDaily: true,
}))
resetActivityStateProvider = posthogModule.__resetActivityStateProviderForTesting
const client = posthogModule.createCliPostHog()
// when
client.trackActive("distinct-cli", "run_started")
// then
expect(captured).toHaveLength(1)
const emittedEvents = captured.map((message) => message.event)
expect(emittedEvents).not.toContain("omo_hourly_active")
const [dailyEvent] = captured
expect(dailyEvent?.event).toBe("omo_daily_active")
expect(dailyEvent?.distinctId).toBe("distinct-cli")
expect(dailyEvent?.properties).toMatchObject({
day_utc: "2026-04-18",
reason: "run_started",
source: "cli",
})
expect(dailyEvent?.properties).not.toHaveProperty("hour_utc")
})
it("emits nothing and never omo_hourly_active when captureDaily is false", async () => {
// given
enableTelemetryEnv()
const captured: CapturedPostHogMessage[] = []
mockPostHogNode(captured)
const posthogModule = await importPostHogModule()
posthogModule.__setActivityStateProviderForTesting(() => ({
dayUTC: "2026-04-18",
captureDaily: false,
}))
resetActivityStateProvider = posthogModule.__resetActivityStateProviderForTesting
const client = posthogModule.createPluginPostHog()
// when
client.trackActive("distinct-plugin", "plugin_loaded")
// then
expect(captured).toHaveLength(0)
const emittedEvents = captured.map((message) => message.event)
expect(emittedEvents).not.toContain("omo_daily_active")
expect(emittedEvents).not.toContain("omo_hourly_active")
})
})
+33 -15
View File
@@ -5,6 +5,25 @@ import packageJson from "../../package.json" with { type: "json" }
import { PLUGIN_NAME, PUBLISHED_PACKAGE_NAME } from "./plugin-identity"
import { getPostHogActivityCaptureState } from "./posthog-activity-state"
/** @internal test-only seam: keep null in production to use the real implementation. */
let activityStateProviderOverride: typeof getPostHogActivityCaptureState | null = null
function resolveActivityState(): ReturnType<typeof getPostHogActivityCaptureState> {
return (activityStateProviderOverride ?? getPostHogActivityCaptureState)()
}
/** @internal test-only */
export function __setActivityStateProviderForTesting(
provider: typeof getPostHogActivityCaptureState,
): void {
activityStateProviderOverride = provider
}
/** @internal test-only */
export function __resetActivityStateProviderForTesting(): void {
activityStateProviderOverride = null
}
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"
@@ -55,7 +74,18 @@ function getPostHogHost(): string {
return process.env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST
}
function safeCpus(): { length: number; model: string | undefined } {
try {
const cpus = os.cpus()
return { length: cpus.length, model: cpus[0]?.model }
} catch {
return { length: 0, model: undefined }
}
}
function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureEvent["properties"]> {
const cpus = safeCpus()
return {
platform: "oh-my-opencode",
package_name: PUBLISHED_PACKAGE_NAME,
@@ -68,8 +98,8 @@ function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureE
$os_version: os.release(),
os_arch: os.arch(),
os_type: os.type(),
cpu_count: os.cpus().length,
cpu_model: os.cpus()[0]?.model,
cpu_count: cpus.length,
cpu_model: cpus.model,
total_memory_gb: Math.round(os.totalmem() / 1024 / 1024 / 1024),
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -117,7 +147,7 @@ function createPostHogClient(
})
},
trackActive: (distinctId, reason) => {
const activityState = getPostHogActivityCaptureState()
const activityState = resolveActivityState()
if (activityState.captureDaily) {
configuredClient.capture({
@@ -130,18 +160,6 @@ function createPostHogClient(
},
})
}
if (activityState.captureHourly) {
configuredClient.capture({
distinctId,
event: "omo_hourly_active",
properties: {
...sharedProperties,
hour_utc: activityState.hourUTC,
reason,
},
})
}
},
shutdown: async () => configuredClient.shutdown(),
}