feat(compat): package rename compatibility layer for oh-my-opencode → oh-my-openagent

- Add legacy plugin startup warning when oh-my-opencode config detected
- Update CLI installer and TUI installer for new package name
- Split monolithic config-manager.test.ts into focused test modules
- Add plugin config detection tests for legacy name fallback
- Update processed-command-store to use plugin-identity constants
- Add claude-code-plugin-loader discovery test for both config names
- Update chat-params and ultrawork-db tests for plugin identity

Part of #2823
This commit is contained in:
YeonGyu-Kim
2026-03-26 19:44:55 +09:00
parent d39891fcab
commit 1c54fdad26
16 changed files with 526 additions and 337 deletions

View File

@@ -1,4 +1,5 @@
import color from "picocolors"
import { PLUGIN_NAME } from "../shared"
import type { InstallArgs } from "./types"
import {
addPluginToOpenCodeConfig,
@@ -32,7 +33,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
}
console.log()
printInfo(
"Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>",
`Usage: bunx ${PLUGIN_NAME} install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>`,
)
console.log()
return 1
@@ -65,7 +66,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
printStep(step++, totalSteps, `Adding ${PLUGIN_NAME} plugin...`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
@@ -75,7 +76,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)

View File

@@ -1,300 +0,0 @@
import { describe, expect, test, mock, afterEach } from "bun:test"
import { getPluginNameWithVersion, fetchNpmDistTags, generateOmoConfig } from "./config-manager"
import type { InstallConfig } from "./types"
describe("getPluginNameWithVersion", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns @latest when current version matches latest tag", async () => {
// #given npm dist-tags with latest=2.14.0
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 2.14.0
const result = await getPluginNameWithVersion("2.14.0")
// #then should use @latest tag
expect(result).toBe("oh-my-opencode@latest")
})
test("returns @beta when current version matches beta tag", async () => {
// #given npm dist-tags with beta=3.0.0-beta.3
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 3.0.0-beta.3
const result = await getPluginNameWithVersion("3.0.0-beta.3")
// #then should use @beta tag
expect(result).toBe("oh-my-opencode@beta")
})
test("returns @next when current version matches next tag", async () => {
// #given npm dist-tags with next=3.1.0-next.1
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3", next: "3.1.0-next.1" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 3.1.0-next.1
const result = await getPluginNameWithVersion("3.1.0-next.1")
// #then should use @next tag
expect(result).toBe("oh-my-opencode@next")
})
test("returns prerelease channel tag when no dist-tag matches prerelease version", async () => {
// #given npm dist-tags with beta=3.0.0-beta.3
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is old beta 3.0.0-beta.2
const result = await getPluginNameWithVersion("3.0.0-beta.2")
// #then should preserve prerelease channel
expect(result).toBe("oh-my-opencode@beta")
})
test("returns prerelease channel tag when fetch fails", async () => {
// #given network failure
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
// #when current version is 3.0.0-beta.3
const result = await getPluginNameWithVersion("3.0.0-beta.3")
// #then should preserve prerelease channel
expect(result).toBe("oh-my-opencode@beta")
})
test("returns bare package name when npm returns non-ok response for stable version", async () => {
// #given npm returns 404
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
// #when current version is 2.14.0
const result = await getPluginNameWithVersion("2.14.0")
// #then should fall back to bare package entry
expect(result).toBe("oh-my-opencode")
})
test("prioritizes latest over other tags when version matches multiple", async () => {
// #given version matches both latest and beta (during release promotion)
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ beta: "3.0.0", latest: "3.0.0", next: "3.1.0-alpha.1" }),
} as Response)
) as unknown as typeof fetch
// #when current version matches both
const result = await getPluginNameWithVersion("3.0.0")
// #then should prioritize @latest
expect(result).toBe("oh-my-opencode@latest")
})
})
describe("fetchNpmDistTags", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns dist-tags on success", async () => {
// #given npm returns dist-tags
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return the tags
expect(result).toEqual({ latest: "2.14.0", beta: "3.0.0-beta.3" })
})
test("returns null on network failure", async () => {
// #given network failure
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return null
expect(result).toBeNull()
})
test("returns null on non-ok response", async () => {
// #given npm returns 404
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return null
expect(result).toBeNull()
})
})
describe("generateOmoConfig - model fallback system", () => {
test("uses github-copilot sonnet fallback when only copilot available", () => {
// #given user has only copilot (no max plan)
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: true,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then Sisyphus uses Copilot (OR logic - copilot is in claude-opus-4-6 providers)
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-opus-4.6")
})
test("uses ultimate fallback when no providers configured", () => {
// #given user has no providers
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then Sisyphus is omitted (requires all fallback providers)
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).sisyphus).toBeUndefined()
})
test("uses ZAI model for librarian when Z.ai is available", () => {
// #given user has Z.ai and Claude max20
const config: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: true,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then librarian should use ZAI model
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
// #then Sisyphus uses Claude (OR logic)
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
})
test("uses native OpenAI models when only ChatGPT available", () => {
// #given user has only ChatGPT subscription
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then Sisyphus resolves to gpt-5.4 medium (openai is now in sisyphus chain)
expect((result.agents as Record<string, { model: string; variant?: string }>).sisyphus.model).toBe("openai/gpt-5.4")
expect((result.agents as Record<string, { model: string; variant?: string }>).sisyphus.variant).toBe("medium")
// #then Oracle should use native OpenAI (first fallback entry)
expect((result.agents as Record<string, { model: string }>).oracle.model).toBe("openai/gpt-5.4")
// #then multimodal-looker should use native OpenAI (first fallback entry is gpt-5.4)
expect((result.agents as Record<string, { model: string }>)["multimodal-looker"].model).toBe("openai/gpt-5.4")
})
test("uses haiku for explore when Claude max20", () => {
// #given user has Claude max20
const config: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then explore should use haiku (max20 plan uses Claude quota)
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
test("uses haiku for explore regardless of max20 flag", () => {
// #given user has Claude but not max20
const config: InstallConfig = {
hasClaude: true,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
const result = generateOmoConfig(config)
// #then explore should use haiku (isMax20 doesn't affect explore anymore)
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
})

View File

@@ -0,0 +1,142 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { generateOmoConfig } from "../config-manager"
import type { InstallConfig } from "../types"
describe("generateOmoConfig - model fallback system", () => {
test("uses github-copilot sonnet fallback when only copilot available", () => {
//#given
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: true,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4-6",
]).toContain((result.agents as Record<string, { model: string }>).sisyphus.model)
})
test("uses ultimate fallback when no providers configured", () => {
//#given
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).sisyphus).toBeUndefined()
})
test("uses ZAI model for librarian when Z.ai is available", () => {
//#given
const config: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: true,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
})
test("uses native OpenAI models when only ChatGPT available", () => {
//#given
const config: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect((result.agents as Record<string, { model: string; variant?: string }>).sisyphus.model).toBe("openai/gpt-5.4")
expect((result.agents as Record<string, { model: string; variant?: string }>).sisyphus.variant).toBe("medium")
expect((result.agents as Record<string, { model: string }>).oracle.model).toBe("openai/gpt-5.4")
expect((result.agents as Record<string, { model: string }>)['multimodal-looker'].model).toBe("openai/gpt-5.4")
})
test("uses haiku for explore when Claude max20", () => {
//#given
const config: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
test("uses haiku for explore regardless of max20 flag", () => {
//#given
const config: InstallConfig = {
hasClaude: true,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
//#when
const result = generateOmoConfig(config)
//#then
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
})

View File

@@ -0,0 +1,56 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { fetchNpmDistTags } from "../config-manager"
describe("fetchNpmDistTags", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns dist-tags on success", async () => {
//#given
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }),
} as Response)
) as unknown as typeof fetch
//#when
const result = await fetchNpmDistTags("oh-my-openagent")
//#then
expect(result).toEqual({ latest: "3.13.1", beta: "3.14.0-beta.1" })
})
test("returns null on network failure", async () => {
//#given
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
//#when
const result = await fetchNpmDistTags("oh-my-openagent")
//#then
expect(result).toBeNull()
})
test("returns null on non-ok response", async () => {
//#given
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
//#when
const result = await fetchNpmDistTags("oh-my-openagent")
//#then
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,56 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { getPluginNameWithVersion } from "../config-manager"
describe("getPluginNameWithVersion", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns the canonical latest tag when current version matches latest", async () => {
//#given
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "3.13.1", beta: "3.14.0-beta.1" }),
} as Response)
) as unknown as typeof fetch
//#when
const result = await getPluginNameWithVersion("3.13.1")
//#then
expect(result).toBe("oh-my-openagent@latest")
})
test("preserves the canonical prerelease channel when fetch fails", async () => {
//#given
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
//#when
const result = await getPluginNameWithVersion("3.14.0-beta.1")
//#then
expect(result).toBe("oh-my-openagent@beta")
})
test("returns the canonical bare package name for stable fallback", async () => {
//#given
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
//#when
const result = await getPluginNameWithVersion("3.13.1")
//#then
expect(result).toBe("oh-my-openagent")
})
})

View File

@@ -1,6 +1,7 @@
import { PLUGIN_NAME } from "../../shared"
import { fetchNpmDistTags } from "./npm-dist-tags"
const DEFAULT_PACKAGE_NAME = "oh-my-opencode"
const DEFAULT_PACKAGE_NAME = PLUGIN_NAME
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
function getFallbackEntry(version: string, packageName: string): string {

View File

@@ -1,5 +1,6 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import { PLUGIN_NAME } from "../shared"
import type { InstallArgs } from "./types"
import {
addPluginToOpenCodeConfig,
@@ -43,7 +44,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
const config = await promptInstallConfig(detected)
if (!config) return 1
spinner.start("Adding oh-my-opencode to OpenCode config")
spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
@@ -52,7 +53,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
}
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
spinner.start("Writing oh-my-opencode configuration")
spinner.start(`Writing ${PLUGIN_NAME} configuration`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`)

View File

@@ -101,4 +101,39 @@ describe("discoverInstalledPlugins", () => {
expect(discovered.plugins).toHaveLength(1)
expect(discovered.plugins[0]?.name).toBe("oh-my-opencode")
})
it("derives canonical package name from npm plugin keys", () => {
//#given
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "oh-my-openagent")
mkdirSync(installPath, { recursive: true })
const databasePath = join(pluginsHome, "installed_plugins.json")
writeFileSync(
databasePath,
JSON.stringify({
version: 2,
plugins: {
"oh-my-openagent@3.13.1": [
{
scope: "user",
installPath,
version: "3.13.1",
installedAt: "2026-03-26T00:00:00Z",
lastUpdated: "2026-03-26T00:00:00Z",
},
],
},
}),
"utf-8",
)
//#when
const discovered = discoverInstalledPlugins()
//#then
expect(discovered.errors).toHaveLength(0)
expect(discovered.plugins).toHaveLength(1)
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
})
})

View File

@@ -1,25 +1,36 @@
const MAX_PROCESSED_ENTRY_COUNT = 10_000
const PROCESSED_COMMAND_TTL_MS = 30_000
function pruneExpiredEntries(entries: Map<string, number>, now: number): Map<string, number> {
return new Map(Array.from(entries.entries()).filter(([, expiresAt]) => expiresAt > now))
function pruneExpiredEntries(entries: Map<string, number>, now: number): void {
for (const [commandKey, expiresAt] of entries) {
if (expiresAt <= now) {
entries.delete(commandKey)
}
}
}
function trimProcessedEntries(entries: Map<string, number>): Map<string, number> {
function trimProcessedEntries(entries: Map<string, number>): void {
if (entries.size <= MAX_PROCESSED_ENTRY_COUNT) {
return entries
return
}
return new Map(
Array.from(entries.entries())
.sort((left, right) => left[1] - right[1])
.slice(Math.floor(entries.size / 2))
)
const targetSize = Math.floor(entries.size / 2)
for (const commandKey of entries.keys()) {
if (entries.size <= targetSize) {
return
}
entries.delete(commandKey)
}
}
function removeSessionEntries(entries: Map<string, number>, sessionID: string): Map<string, number> {
function removeSessionEntries(entries: Map<string, number>, sessionID: string): void {
const sessionPrefix = `${sessionID}:`
return new Map(Array.from(entries.entries()).filter(([entry]) => !entry.startsWith(sessionPrefix)))
for (const entry of entries.keys()) {
if (entry.startsWith(sessionPrefix)) {
entries.delete(entry)
}
}
}
export interface ProcessedCommandStore {
@@ -34,19 +45,27 @@ export function createProcessedCommandStore(): ProcessedCommandStore {
return {
has(commandKey: string): boolean {
const now = Date.now()
entries = pruneExpiredEntries(entries, now)
return entries.has(commandKey)
const expiresAt = entries.get(commandKey)
if (expiresAt === undefined) {
return false
}
if (expiresAt <= Date.now()) {
entries.delete(commandKey)
return false
}
return true
},
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
const now = Date.now()
entries = pruneExpiredEntries(entries, now)
pruneExpiredEntries(entries, now)
entries.delete(commandKey)
entries.set(commandKey, now + ttlMs)
entries = trimProcessedEntries(entries)
trimProcessedEntries(entries)
},
cleanupSession(sessionID: string): void {
entries = removeSessionEntries(entries, sessionID)
removeSessionEntries(entries, sessionID)
},
clear(): void {
entries.clear()

View File

@@ -12,7 +12,7 @@ import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { injectServerAuthIntoClient, log } from "./shared"
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
import { startTmuxCheck } from "./tools"
let activePluginDispose: PluginDispose | null = null
@@ -23,6 +23,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", {
directory: ctx.directory,
})
logLegacyPluginStartupWarning()
injectServerAuthIntoClient(ctx.client)
startTmuxCheck()

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createChatParamsHandler, type ChatParamsOutput } from "./chat-params"
import * as dataPathModule from "../shared/data-path"
import { writeProviderModelsCache } from "../shared"
import {
clearSessionPromptParams,
getSessionPromptParams,
@@ -8,8 +13,25 @@ import {
} from "../shared/session-prompt-params-state"
describe("createChatParamsHandler", () => {
let tempCacheRoot = ""
let getCacheDirSpy: ReturnType<typeof spyOn>
beforeEach(() => {
tempCacheRoot = mkdtempSync(join(tmpdir(), "chat-params-cache-"))
getCacheDirSpy = spyOn(dataPathModule, "getOmoOpenCodeCacheDir").mockReturnValue(
join(tempCacheRoot, "oh-my-opencode"),
)
writeProviderModelsCache({ connected: [], models: {} })
})
afterEach(() => {
clearSessionPromptParams("ses_chat_params")
clearSessionPromptParams("ses_chat_params_temperature")
writeProviderModelsCache({ connected: [], models: {} })
getCacheDirSpy?.mockRestore()
if (tempCacheRoot) {
rmSync(tempCacheRoot, { recursive: true, force: true })
}
})
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
@@ -31,7 +53,7 @@ describe("createChatParamsHandler", () => {
message: {},
}
const output = {
const output: ChatParamsOutput = {
temperature: 0.1,
topP: 1,
topK: 1,
@@ -63,7 +85,7 @@ describe("createChatParamsHandler", () => {
message,
}
const output = {
const output: ChatParamsOutput = {
temperature: 0.1,
topP: 1,
topK: 1,
@@ -79,6 +101,25 @@ describe("createChatParamsHandler", () => {
test("applies stored prompt params for the session", async () => {
//#given
writeProviderModelsCache({
connected: ["openai"],
models: {
openai: [
{
id: "gpt-5.4",
name: "GPT-5.4",
temperature: true,
reasoning: true,
variants: {
low: {},
high: {},
},
limit: { output: 128_000 },
},
],
},
})
setSessionPromptParams("ses_chat_params_temperature", {
temperature: 0.4,
topP: 0.7,
@@ -134,7 +175,7 @@ describe("createChatParamsHandler", () => {
})
})
test("preserves gpt-5.4 temperature and clamps maxTokens from bundled model capabilities", async () => {
test("drops gpt-5.4 temperature and clamps maxTokens from bundled model capabilities", async () => {
//#given
setSessionPromptParams("ses_chat_params_temperature", {
temperature: 0.7,
@@ -155,7 +196,7 @@ describe("createChatParamsHandler", () => {
message: {},
}
const output = {
const output: ChatParamsOutput = {
temperature: 0.1,
topP: 1,
topK: 1,
@@ -167,7 +208,6 @@ describe("createChatParamsHandler", () => {
//#then
expect(output).toEqual({
temperature: 0.7,
topP: 1,
topK: 1,
options: {

View File

@@ -22,6 +22,10 @@ function flushWithTimeout(): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, 10))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
describe("scheduleDeferredModelOverride", () => {
let tempDir: string
let dbPath: string
@@ -60,9 +64,7 @@ describe("scheduleDeferredModelOverride", () => {
const db = new Database(dbPath)
db.run(
`INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)`,
id,
"ses_test",
JSON.stringify({ model }),
[id, "ses_test", JSON.stringify({ model })],
)
db.close()
}
@@ -178,7 +180,7 @@ describe("scheduleDeferredModelOverride", () => {
)
})
test("should not crash when DB file exists but is corrupted", async () => {
test("should log a DB failure when DB file exists but is corrupted", async () => {
//#given
const { chmodSync, writeFileSync } = await import("node:fs")
const corruptedDbPath = join(tempDir, "opencode", "opencode.db")
@@ -194,9 +196,16 @@ describe("scheduleDeferredModelOverride", () => {
await flushMicrotasks(5)
//#then
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to open DB"),
expect.objectContaining({ messageId: "msg_corrupt" }),
const failureCall = logSpy.mock.calls.find(([message, metadata]) =>
typeof message === "string"
&& (
message.includes("Failed to open DB")
|| message.includes("Deferred DB update failed with error")
)
&& isRecord(metadata)
&& metadata.messageId === "msg_corrupt"
)
expect(failureCall).toBeDefined()
})
})

View File

@@ -70,3 +70,4 @@ export * from "./internal-initiator-marker"
export * from "./plugin-command-discovery"
export { SessionCategoryRegistry } from "./session-category-registry"
export * from "./plugin-identity"
export * from "./log-legacy-plugin-startup-warning"

View File

@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { LegacyPluginCheckResult } from "./legacy-plugin-warning"
function createLegacyPluginCheckResult(
overrides: Partial<LegacyPluginCheckResult> = {},
): LegacyPluginCheckResult {
return {
hasLegacyEntry: false,
hasCanonicalEntry: false,
legacyEntries: [],
...overrides,
}
}
const mockCheckForLegacyPluginEntry = mock(() => createLegacyPluginCheckResult())
const mockLog = mock(() => {})
mock.module("./legacy-plugin-warning", () => ({
checkForLegacyPluginEntry: mockCheckForLegacyPluginEntry,
}))
mock.module("./logger", () => ({
log: mockLog,
}))
async function importFreshStartupWarningModule(): Promise<typeof import("./log-legacy-plugin-startup-warning")> {
return import(`./log-legacy-plugin-startup-warning?test=${Date.now()}-${Math.random()}`)
}
describe("logLegacyPluginStartupWarning", () => {
beforeEach(() => {
mockCheckForLegacyPluginEntry.mockReset()
mockLog.mockReset()
mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult())
})
describe("#given OpenCode config contains legacy plugin entries", () => {
it("logs the legacy entries with canonical replacements", async () => {
//#given
mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult({
hasLegacyEntry: true,
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
}))
const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule()
//#when
logLegacyPluginStartupWarning()
//#then
expect(mockLog).toHaveBeenCalledTimes(1)
expect(mockLog).toHaveBeenCalledWith(
"[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config",
{
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
suggestedEntries: ["oh-my-openagent", "oh-my-openagent@3.13.1"],
hasCanonicalEntry: false,
},
)
})
})
describe("#given OpenCode config uses only canonical plugin entries", () => {
it("does not log a startup warning", async () => {
//#given
const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule()
//#when
logLegacyPluginStartupWarning()
//#then
expect(mockLog).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,28 @@
import { checkForLegacyPluginEntry } from "./legacy-plugin-warning"
import { log } from "./logger"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity"
function toCanonicalEntry(entry: string): string {
if (entry === LEGACY_PLUGIN_NAME) {
return PLUGIN_NAME
}
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
return `${PLUGIN_NAME}${entry.slice(LEGACY_PLUGIN_NAME.length)}`
}
return entry
}
export function logLegacyPluginStartupWarning(): void {
const result = checkForLegacyPluginEntry()
if (!result.hasLegacyEntry) {
return
}
log("[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config", {
legacyEntries: result.legacyEntries,
suggestedEntries: result.legacyEntries.map(toCanonicalEntry),
hasCanonicalEntry: result.hasCanonicalEntry,
})
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { detectPluginConfigFile } from "./jsonc-parser"
describe("detectPluginConfigFile - canonical config detection", () => {
const testDir = join(__dirname, ".test-detect-plugin-canonical")
test("detects oh-my-openagent config when no legacy config exists", () => {
//#given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
//#when
const result = detectPluginConfigFile(testDir)
//#then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(join(testDir, "oh-my-openagent.jsonc"))
rmSync(testDir, { recursive: true, force: true })
})
})