From 1c54fdad26e9381033d539bb178ea2fa054e541c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 19:44:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(compat):=20package=20rename=20compatibilit?= =?UTF-8?q?y=20layer=20for=20oh-my-opencode=20=E2=86=92=20oh-my-openagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/cli/cli-installer.ts | 7 +- src/cli/config-manager.test.ts | 300 ------------------ .../generate-omo-config.test.ts | 142 +++++++++ src/cli/config-manager/npm-dist-tags.test.ts | 56 ++++ .../plugin-name-with-version.test.ts | 56 ++++ .../plugin-name-with-version.ts | 3 +- src/cli/tui-installer.ts | 5 +- .../discovery.test.ts | 35 ++ .../processed-command-store.ts | 53 +++- src/index.ts | 3 +- src/plugin/chat-params.test.ts | 52 ++- .../ultrawork-db-model-override.test.ts | 23 +- src/shared/index.ts | 1 + .../log-legacy-plugin-startup-warning.test.ts | 76 +++++ .../log-legacy-plugin-startup-warning.ts | 28 ++ src/shared/plugin-config-detection.test.ts | 23 ++ 16 files changed, 526 insertions(+), 337 deletions(-) delete mode 100644 src/cli/config-manager.test.ts create mode 100644 src/cli/config-manager/generate-omo-config.test.ts create mode 100644 src/cli/config-manager/npm-dist-tags.test.ts create mode 100644 src/cli/config-manager/plugin-name-with-version.test.ts create mode 100644 src/shared/log-legacy-plugin-startup-warning.test.ts create mode 100644 src/shared/log-legacy-plugin-startup-warning.ts create mode 100644 src/shared/plugin-config-detection.test.ts diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index b97141bca..9f51eedfb 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -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= --gemini= --copilot=", + `Usage: bunx ${PLUGIN_NAME} install --no-tui --claude= --gemini= --copilot=`, ) 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}`) diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts deleted file mode 100644 index 536695a76..000000000 --- a/src/cli/config-manager.test.ts +++ /dev/null @@ -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).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).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).librarian.model).toBe("zai-coding-plan/glm-4.7") - // #then Sisyphus uses Claude (OR logic) - expect((result.agents as Record).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).sisyphus.model).toBe("openai/gpt-5.4") - expect((result.agents as Record).sisyphus.variant).toBe("medium") - // #then Oracle should use native OpenAI (first fallback entry) - expect((result.agents as Record).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)["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).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).explore.model).toBe("anthropic/claude-haiku-4-5") - }) -}) diff --git a/src/cli/config-manager/generate-omo-config.test.ts b/src/cli/config-manager/generate-omo-config.test.ts new file mode 100644 index 000000000..2a1a24e5b --- /dev/null +++ b/src/cli/config-manager/generate-omo-config.test.ts @@ -0,0 +1,142 @@ +/// + +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).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).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).librarian.model).toBe("zai-coding-plan/glm-4.7") + expect((result.agents as Record).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).sisyphus.model).toBe("openai/gpt-5.4") + expect((result.agents as Record).sisyphus.variant).toBe("medium") + expect((result.agents as Record).oracle.model).toBe("openai/gpt-5.4") + expect((result.agents as Record)['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).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).explore.model).toBe("anthropic/claude-haiku-4-5") + }) +}) diff --git a/src/cli/config-manager/npm-dist-tags.test.ts b/src/cli/config-manager/npm-dist-tags.test.ts new file mode 100644 index 000000000..3de417290 --- /dev/null +++ b/src/cli/config-manager/npm-dist-tags.test.ts @@ -0,0 +1,56 @@ +/// + +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() + }) +}) diff --git a/src/cli/config-manager/plugin-name-with-version.test.ts b/src/cli/config-manager/plugin-name-with-version.test.ts new file mode 100644 index 000000000..7da003338 --- /dev/null +++ b/src/cli/config-manager/plugin-name-with-version.test.ts @@ -0,0 +1,56 @@ +/// + +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") + }) +}) diff --git a/src/cli/config-manager/plugin-name-with-version.ts b/src/cli/config-manager/plugin-name-with-version.ts index 4dfa7cf6b..a5b034278 100644 --- a/src/cli/config-manager/plugin-name-with-version.ts +++ b/src/cli/config-manager/plugin-name-with-version.ts @@ -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 { diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 410416842..49fc0c5db 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -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}`) diff --git a/src/features/claude-code-plugin-loader/discovery.test.ts b/src/features/claude-code-plugin-loader/discovery.test.ts index 63e2340a6..d42286579 100644 --- a/src/features/claude-code-plugin-loader/discovery.test.ts +++ b/src/features/claude-code-plugin-loader/discovery.test.ts @@ -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") + }) }) diff --git a/src/hooks/auto-slash-command/processed-command-store.ts b/src/hooks/auto-slash-command/processed-command-store.ts index 2f6d86949..94e69428d 100644 --- a/src/hooks/auto-slash-command/processed-command-store.ts +++ b/src/hooks/auto-slash-command/processed-command-store.ts @@ -1,25 +1,36 @@ const MAX_PROCESSED_ENTRY_COUNT = 10_000 const PROCESSED_COMMAND_TTL_MS = 30_000 -function pruneExpiredEntries(entries: Map, now: number): Map { - return new Map(Array.from(entries.entries()).filter(([, expiresAt]) => expiresAt > now)) +function pruneExpiredEntries(entries: Map, now: number): void { + for (const [commandKey, expiresAt] of entries) { + if (expiresAt <= now) { + entries.delete(commandKey) + } + } } -function trimProcessedEntries(entries: Map): Map { +function trimProcessedEntries(entries: Map): 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, sessionID: string): Map { +function removeSessionEntries(entries: Map, 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() diff --git a/src/index.ts b/src/index.ts index c97fedcd7..506e65c35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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() diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index 622b38374..5f17f36eb 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -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 + + 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: { diff --git a/src/plugin/ultrawork-db-model-override.test.ts b/src/plugin/ultrawork-db-model-override.test.ts index db364a97b..a5b350e75 100644 --- a/src/plugin/ultrawork-db-model-override.test.ts +++ b/src/plugin/ultrawork-db-model-override.test.ts @@ -22,6 +22,10 @@ function flushWithTimeout(): Promise { return new Promise((resolve) => setTimeout(resolve, 10)) } +function isRecord(value: unknown): value is Record { + 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() }) }) diff --git a/src/shared/index.ts b/src/shared/index.ts index ee690e816..e178952b5 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/log-legacy-plugin-startup-warning.test.ts b/src/shared/log-legacy-plugin-startup-warning.test.ts new file mode 100644 index 000000000..f4a23786c --- /dev/null +++ b/src/shared/log-legacy-plugin-startup-warning.test.ts @@ -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 { + 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 { + 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() + }) + }) +}) diff --git a/src/shared/log-legacy-plugin-startup-warning.ts b/src/shared/log-legacy-plugin-startup-warning.ts new file mode 100644 index 000000000..f5712a2e9 --- /dev/null +++ b/src/shared/log-legacy-plugin-startup-warning.ts @@ -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, + }) +} diff --git a/src/shared/plugin-config-detection.test.ts b/src/shared/plugin-config-detection.test.ts new file mode 100644 index 000000000..34ad9b434 --- /dev/null +++ b/src/shared/plugin-config-detection.test.ts @@ -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 }) + }) +})