Merge remote-tracking branch 'origin/dev' into opencode/mighty-wolf
This commit is contained in:
@@ -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.0–v3.16.0 sessions", () => {
|
||||
expect(stripAgentListSortPrefix("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeAgentForPrompt", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}`)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user