From 37057f18b9e59fa0110ecccb839be0b801858851 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 10 Apr 2026 15:53:35 +0900 Subject: [PATCH] test(tools): update MCP, delegate-task, skill, and slashcommand tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/mcp/websearch.test.ts | 40 +++-- .../__tests__/reply-listener-discord.test.ts | 15 +- .../delegate-task/sync-continuation.test.ts | 6 +- src/tools/delegate-task/sync-task.test.ts | 9 ++ src/tools/delegate-task/task-schema.test.ts | 14 +- src/tools/delegate-task/tools.test.ts | 59 ++++++- .../subagent-resolver.test.ts | 148 +++++++++--------- src/tools/session-manager/tools.test.ts | 76 ++++++++- .../skill/async-description-refresh.test.ts | 13 +- .../slashcommand/command-discovery.test.ts | 13 +- .../execution-compatibility.test.ts | 18 ++- ...opencode-project-command-discovery.test.ts | 13 +- 12 files changed, 312 insertions(+), 112 deletions(-) diff --git a/src/mcp/websearch.test.ts b/src/mcp/websearch.test.ts index c525683e2..707961670 100644 --- a/src/mcp/websearch.test.ts +++ b/src/mcp/websearch.test.ts @@ -1,36 +1,50 @@ /// import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" -import { createWebsearchConfig } from "./websearch" -import * as shared from "../shared" +import * as logger from "../shared/logger" let logSpy: ReturnType +let createWebsearchConfig: (typeof import("./websearch"))["createWebsearchConfig"] +let originalEnv: Record<"EXA_API_KEY" | "TAVILY_API_KEY", string | undefined> -beforeEach(() => { - logSpy = spyOn(shared, "log").mockImplementation(() => {}) +async function importFreshWebsearchModule(): Promise { + return import(`./websearch?test=${Date.now()}-${Math.random()}`) +} + +beforeEach(async () => { + originalEnv = { + EXA_API_KEY: process.env.EXA_API_KEY, + TAVILY_API_KEY: process.env.TAVILY_API_KEY, + } + delete process.env.EXA_API_KEY + delete process.env.TAVILY_API_KEY + logSpy = spyOn(logger, "log").mockImplementation(() => {}) + ;({ createWebsearchConfig } = await importFreshWebsearchModule()) }) afterEach(() => { logSpy.mockRestore() + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + continue + } + + process.env[key] = value + } }) describe("createWebsearchConfig Tavily handling", () => { test("returns undefined when Tavily API key is missing", () => { - const originalEnv = process.env.TAVILY_API_KEY delete process.env.TAVILY_API_KEY const config = createWebsearchConfig({ provider: "tavily" }) expect(config).toBeUndefined() expect(logSpy).toHaveBeenCalledWith("[websearch] Tavily API key not found, skipping websearch MCP") - - if (originalEnv) { - process.env.TAVILY_API_KEY = originalEnv - } }) test("returns valid config when Tavily API key is present", () => { - const originalEnv = process.env.TAVILY_API_KEY process.env.TAVILY_API_KEY = "test-key" const config = createWebsearchConfig({ provider: "tavily" }) @@ -38,11 +52,5 @@ describe("createWebsearchConfig Tavily handling", () => { expect(config).toBeDefined() expect(config?.type).toBe("remote") expect(config?.url).toBe("https://mcp.tavily.com/mcp/") - - if (originalEnv) { - process.env.TAVILY_API_KEY = originalEnv - } else { - delete process.env.TAVILY_API_KEY - } }) }) diff --git a/src/openclaw/__tests__/reply-listener-discord.test.ts b/src/openclaw/__tests__/reply-listener-discord.test.ts index d1bdba01e..8fcc77c03 100644 --- a/src/openclaw/__tests__/reply-listener-discord.test.ts +++ b/src/openclaw/__tests__/reply-listener-discord.test.ts @@ -9,8 +9,6 @@ import * as sessionRegistryModule from "../session-registry" import type { ReplyListenerDaemonState } from "../reply-listener-state" import type { OpenClawConfig } from "../types" -const originalHome = process.env.HOME -const originalUserProfile = process.env.USERPROFILE const originalFetch = globalThis.fetch const tempHome = mkdtempSync(join(tmpdir(), "openclaw-reply-listener-discord-")) @@ -72,17 +70,18 @@ describe("pollDiscordReplies", () => { }) test("records HTTP failures in daemon state when Discord returns non-ok", async () => { - const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + const fetchMock = mock(() => Promise.resolve( new Response("unauthorized", { status: 401, }), - ) + )) + globalThis.fetch = fetchMock as unknown as typeof fetch const state = createState() await pollDiscordReplies(createConfig(), state, new ReplyListenerRateLimiter(10)) - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) expect(state.errors).toBe(1) expect(state.lastError).toBe("Discord API error: HTTP 401") expect(existsSync(stateFilePath)).toBe(true) @@ -94,7 +93,8 @@ describe("pollDiscordReplies", () => { }) test("increments messagesInjected when a Discord reply matches a registered message", async () => { - const fetchSpy = spyOn(globalThis, "fetch") + const fetchMock = mock() + fetchMock .mockResolvedValueOnce( new Response( JSON.stringify([ @@ -109,6 +109,7 @@ describe("pollDiscordReplies", () => { ), ) .mockResolvedValueOnce(new Response(null, { status: 204 })) + globalThis.fetch = fetchMock as unknown as typeof fetch const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({ sessionId: "ses-1", tmuxSession: "session-1", @@ -126,7 +127,7 @@ describe("pollDiscordReplies", () => { expect(lookupSpy).toHaveBeenCalledWith("discord-bot", "outbound-1") expect(injectSpy).toHaveBeenCalledWith("%7", "Ship it", "discord", createConfig()) - expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(2) expect(state.messagesSeen).toBe(1) expect(state.messagesInjected).toBe(1) expect(state.lastDiscordMessageId).toBe("incoming-1") diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 21bd6c55e..fa05e93ab 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -605,8 +605,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { }) }) - test("keeps task delegation disabled during prometheus sync continuation", async () => { - //#given - a resumed prometheus session should stay unable to delegate tasks + test("keeps task delegation enabled during prometheus sync continuation", async () => { + //#given - a resumed prometheus session should keep plan-family task permission const promptAsyncCalls: Array<{ path: { id: string }; body: Record }> = [] const mockClient = { session: { @@ -667,7 +667,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls[0]?.body.tools).toEqual({ - task: false, + task: true, call_omo_agent: true, question: false, }) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index b34a3cc6c..30c9199c3 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -1,5 +1,12 @@ const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test") +function clearRequireCache(modulePath: string): void { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } +} + describe("executeSyncTask - cleanup on error paths", () => { let removeTaskCalls: string[] = [] let addTaskCalls: any[] = [] @@ -23,6 +30,8 @@ describe("executeSyncTask - cleanup on error paths", () => { deleteCalls = [] addCalls = [] + clearRequireCache("./sync-task") + //#given - initialize real task toast manager (avoid global module mocks) const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager") _resetTaskToastManagerForTesting() diff --git a/src/tools/delegate-task/task-schema.test.ts b/src/tools/delegate-task/task-schema.test.ts index 2f3485195..958b9f0a6 100644 --- a/src/tools/delegate-task/task-schema.test.ts +++ b/src/tools/delegate-task/task-schema.test.ts @@ -1,6 +1,16 @@ -declare const require: (name: string) => any const { describe, expect, test } = require("bun:test") -import { createDelegateTask } from "./tools" + +function requireFresh(modulePath: string): T { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } + return require(modulePath) as T +} + +function createDelegateTask(...args: Parameters): ReturnType { + return requireFresh("./tools").createDelegateTask(...args) +} describe("createDelegateTask schema", () => { test("#given category arg #when tool is created #then category accepts any string", () => { diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index cda38592d..3e7c242b2 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1,7 +1,7 @@ -declare const require: (name: string) => any +declare const require: NodeJS.Require const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test") import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants" -import { resolveCategoryConfig } from "./tools" +import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names" import type { CategoryConfig } from "../../config/schema" import type { DelegateTaskArgs } from "./types" import { __resetModelCache } from "../../shared/model-availability" @@ -10,6 +10,20 @@ import { __setTimingConfig, __resetTimingConfig } from "./timing" import * as connectedProvidersCache from "../../shared/connected-providers-cache" import * as executor from "./executor" +const runtimeRequire = require as NodeJS.Require & { cache?: Record } + +function clearRequireCache(modulePath: string): void { + const resolvedPath = runtimeRequire.resolve(modulePath) + if (runtimeRequire.cache?.[resolvedPath]) { + delete runtimeRequire.cache[resolvedPath] + } +} + +function resolveCategoryConfig(...args: Parameters): ReturnType { + clearRequireCache("./tools") + return require("./tools").resolveCategoryConfig(...args) +} + const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6" const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"] @@ -37,6 +51,7 @@ describe("sisyphus-task", () => { beforeEach(() => { mock.restore() + clearRequireCache("./tools") __resetModelCache() clearSkillCache() __setTimingConfig({ @@ -253,6 +268,20 @@ describe("sisyphus-task", () => { expect(result).toBe(true) }) + test("returns true for prometheus display name", () => { + //#given / #when + const result = isPlanFamily(getAgentDisplayName("prometheus")) + //#then + expect(result).toBe(true) + }) + + test("returns true for prometheus list display name with zwsp prefix", () => { + //#given / #when + const result = isPlanFamily(getAgentListDisplayName("prometheus")) + //#then + expect(result).toBe(true) + }) + test("returns false for 'oracle'", () => { //#given / #when const result = isPlanFamily("oracle") @@ -3608,6 +3637,26 @@ describe("sisyphus-task", () => { expect(result).toContain("plan-family") }) + test("prometheus display name cannot delegate to plan (cross-blocking)", async () => { + //#given + const { createDelegateTask } = require("./tools") + const mockClient = { + app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) }, + config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, + session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) }, + } + const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient }) + + //#when + const result = await tool.execute( + { description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] }, + { sessionID: "p", messageID: "m", agent: getAgentDisplayName("prometheus"), abort: new AbortController().signal } + ) + + //#then + expect(result).toContain("plan-family") + }) + test("plan cannot delegate to prometheus (cross-blocking)", async () => { //#given const { createDelegateTask } = require("./tools") @@ -4105,7 +4154,7 @@ describe("sisyphus-task", () => { expect(promptBody.tools.task).toBe(true) }, { timeout: 20000 }) - test("prometheus subagent should NOT have task permission", async () => { + test("prometheus subagent should have task permission as part of the plan family", async () => { //#given const { createDelegateTask } = require("./tools") let promptBody: any @@ -4130,8 +4179,8 @@ describe("sisyphus-task", () => { { sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal } ) - //#then - expect(promptBody.tools.task).toBe(false) + //#then - prometheus shares task permission with the plan family + expect(promptBody.tools.task).toBe(true) }, { timeout: 20000 }) test("non-plan subagent should NOT have task permission", async () => { diff --git a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts index 2a38af4d7..bf3c4f370 100644 --- a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts +++ b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts @@ -1,12 +1,20 @@ -declare const require: (name: string) => any -const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test") +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" import type { DelegateTaskArgs } from "../types" import type { ExecutorContext } from "../executor-types" -import * as logger from "../../../shared/logger" -import * as connectedProvidersCache from "../../../shared/connected-providers-cache" type SubagentResolverModule = typeof import("../subagent-resolver") +const logMock = mock((..._args: unknown[]) => {}) + +const readConnectedProvidersCacheMock = mock(() => null as string[] | null) +const readProviderModelsCacheMock = mock( + () => null as { + models: Record + connected: string[] + updatedAt: string + } | null, +) + async function importFreshSubagentResolverModule(): Promise { return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`) } @@ -41,17 +49,30 @@ function createExecutorContext( } describe("resolveSubagentExecution", () => { - let logSpy: ReturnType | undefined let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"] beforeEach(async () => { mock.restore() - logSpy = spyOn(logger, "log").mockImplementation(() => {}) + logMock.mockClear() + readConnectedProvidersCacheMock.mockReset() + readProviderModelsCacheMock.mockReset() + readConnectedProvidersCacheMock.mockReturnValue(null) + readProviderModelsCacheMock.mockReturnValue(null) + mock.module("../../../shared/logger", () => ({ + log: logMock, + })) + mock.module("../../../shared/connected-providers-cache", () => ({ + readConnectedProvidersCache: readConnectedProvidersCacheMock, + readProviderModelsCache: readProviderModelsCacheMock, + hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null, + hasProviderModelsCache: () => readProviderModelsCacheMock() !== null, + _resetMemCacheForTesting: () => {}, + })) ;({ resolveSubagentExecution } = await importFreshSubagentResolverModule()) }) afterEach(() => { - logSpy?.mockRestore() + mock.restore() }) test("returns delegation error when agent discovery fails instead of silently proceeding", async () => { @@ -71,7 +92,7 @@ describe("resolveSubagentExecution", () => { expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable") }) - test("logs failure details when subagent resolution throws", async () => { + test("returns delegation error when subagent resolution throws", async () => { //#given const args = createBaseArgs({ subagent_type: "review" }) const executorCtx = createExecutorContext(async () => { @@ -79,17 +100,12 @@ describe("resolveSubagentExecution", () => { }) //#when - await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") //#then - expect(logSpy).toHaveBeenCalledTimes(1) - const callArgs = logSpy?.mock.calls[0] - expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution") - expect(callArgs?.[1]).toEqual({ - requestedAgent: "review", - parentAgent: "sisyphus", - error: "network timeout", - }) + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Failed to delegate to agent "review": network timeout') }) test("hides primary agents from task delegation lookups", async () => { @@ -129,7 +145,7 @@ describe("resolveSubagentExecution", () => { test("normalizes matched agent model string before returning categoryModel", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["grok-3", "gpt-5.3-codex"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", @@ -145,12 +161,11 @@ describe("resolveSubagentExecution", () => { //#then expect(result.error).toBeUndefined() expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" }) - cacheSpy.mockRestore() }) test("uses agent override fallback_models for subagent runtime fallback chain", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { quotio: ["claude-haiku-4-5"] }, connected: ["quotio"], updatedAt: "2026-03-03T00:00:00.000Z", @@ -178,12 +193,11 @@ describe("resolveSubagentExecution", () => { { providers: ["quotio"], model: "gpt-5.2", variant: undefined }, { providers: ["quotio"], model: "glm-5", variant: "max" }, ]) - cacheSpy.mockRestore() }) test("uses category fallback_models when agent override points at category", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { anthropic: ["claude-haiku-4-5"] }, connected: ["anthropic"], updatedAt: "2026-03-03T00:00:00.000Z", @@ -215,17 +229,16 @@ describe("resolveSubagentExecution", () => { expect(result.fallbackChain).toEqual([ { providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined }, ]) - cacheSpy.mockRestore() }) test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -265,18 +278,16 @@ describe("resolveSubagentExecution", () => { maxTokens: 2048, thinking: { type: "disabled" }, }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4-preview"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -306,18 +317,16 @@ describe("resolveSubagentExecution", () => { providerID: "openai", modelID: "gpt-5.4-preview", }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("matches promoted fallback settings after fuzzy model resolution", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4-preview"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -357,18 +366,16 @@ describe("resolveSubagentExecution", () => { maxTokens: 2222, thinking: { type: "disabled" }, }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4-preview"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -405,18 +412,16 @@ describe("resolveSubagentExecution", () => { variant: "max", reasoningEffort: "high", }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4o"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -448,18 +453,16 @@ describe("resolveSubagentExecution", () => { variant: "low", reasoningEffort: "high", }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { minimaxi: ["MiniMax-M2.7"] }, connected: ["minimaxi"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"]) + readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"]) const args = createBaseArgs({ subagent_type: "my-custom-agent" }) const executorCtx = createExecutorContext( async () => ([ @@ -473,18 +476,16 @@ describe("resolveSubagentExecution", () => { //#then expect(result.error).toBeUndefined() expect(result.categoryModel?.modelID).not.toBe("MiniMax-M2.7-highspeed") - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("uses matchedAgent.model as fallback when model is available", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { minimaxi: ["MiniMax-M2.7-highspeed"] }, connected: ["minimaxi"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"]) + readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"]) const args = createBaseArgs({ subagent_type: "my-custom-agent" }) const executorCtx = createExecutorContext( async () => ([ @@ -498,18 +499,16 @@ describe("resolveSubagentExecution", () => { //#then expect(result.error).toBeUndefined() expect(result.categoryModel).toEqual({ providerID: "minimaxi", modelID: "MiniMax-M2.7-highspeed" }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("prefers the most specific prefix match when fallback entries share a prefix", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-4o-preview"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -546,18 +545,16 @@ describe("resolveSubagentExecution", () => { variant: "max", reasoningEffort: "high", }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("preserves category temperature when fallback entry leaves temperature undefined", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: { openai: ["gpt-5.4"] }, connected: ["openai"], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + readConnectedProvidersCacheMock.mockReturnValue(["openai"]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -596,18 +593,16 @@ describe("resolveSubagentExecution", () => { temperature: 0.55, top_p: 0.45, }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) test("applies category tuning params in the cold-cache override path", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: {}, connected: [], updatedAt: "2026-03-03T00:00:00.000Z", }) - const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue([]) + readConnectedProvidersCacheMock.mockReturnValue([]) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -648,27 +643,39 @@ describe("resolveSubagentExecution", () => { reasoningEffort: "medium", thinking: { type: "disabled" }, }) - cacheSpy.mockRestore() - connectedSpy.mockRestore() }) }) describe("resolveSubagentExecution - agent name sanitization", () => { - let logSpy: ReturnType | undefined let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"] beforeEach(async () => { - logSpy = spyOn(logger, "log").mockImplementation(() => {}) + mock.restore() + logMock.mockClear() + readConnectedProvidersCacheMock.mockReset() + readProviderModelsCacheMock.mockReset() + readConnectedProvidersCacheMock.mockReturnValue(null) + readProviderModelsCacheMock.mockReturnValue(null) + mock.module("../../../shared/logger", () => ({ + log: logMock, + })) + mock.module("../../../shared/connected-providers-cache", () => ({ + readConnectedProvidersCache: readConnectedProvidersCacheMock, + readProviderModelsCache: readProviderModelsCacheMock, + hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null, + hasProviderModelsCache: () => readProviderModelsCacheMock() !== null, + _resetMemCacheForTesting: () => {}, + })) ;({ resolveSubagentExecution } = await importFreshSubagentResolverModule()) }) afterEach(() => { - logSpy?.mockRestore() + mock.restore() }) test("strips backslash-wrapped agent names like \\hephaestus\\", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: {}, connected: [], updatedAt: "2026-03-03T00:00:00.000Z", @@ -684,12 +691,11 @@ describe("resolveSubagentExecution - agent name sanitization", () => { //#then expect(result.error).toBeUndefined() expect(result.agentToUse).toBe("Hephaestus - Deep Agent") - cacheSpy.mockRestore() }) test("strips double-quoted agent names", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: {}, connected: [], updatedAt: "2026-03-03T00:00:00.000Z", @@ -705,12 +711,11 @@ describe("resolveSubagentExecution - agent name sanitization", () => { //#then expect(result.error).toBeUndefined() expect(result.agentToUse).toBe("oracle") - cacheSpy.mockRestore() }) test("strips single-quoted agent names", async () => { //#given - const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + readProviderModelsCacheMock.mockReturnValue({ models: {}, connected: [], updatedAt: "2026-03-03T00:00:00.000Z", @@ -726,6 +731,5 @@ describe("resolveSubagentExecution - agent name sanitization", () => { //#then expect(result.error).toBeUndefined() expect(result.agentToUse).toBe("explore") - cacheSpy.mockRestore() }) }) diff --git a/src/tools/session-manager/tools.test.ts b/src/tools/session-manager/tools.test.ts index a7888a762..315d4f20c 100644 --- a/src/tools/session-manager/tools.test.ts +++ b/src/tools/session-manager/tools.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { createSessionManagerTools } from "./tools" import type { ToolContext } from "@opencode-ai/plugin/tool" import type { PluginInput } from "@opencode-ai/plugin" +import type { SessionInfo, SessionMessage, SearchResult, SessionMetadata, TodoItem } from "./types" const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" @@ -18,23 +19,83 @@ const mockContext: ToolContext = { ask: async () => {}, } -const tools = createSessionManagerTools(mockCtx) -const { session_list, session_read, session_search, session_info } = tools +function createTestTools() { + return createSessionManagerTools(mockCtx, { + setStorageClient: () => {}, + getMainSessions: async (): Promise => [ + { + id: "ses_test123", + projectID: "project-1", + directory: projectDir, + time: { created: Date.now(), updated: Date.now() }, + }, + { + id: "ses_test456", + projectID: "project-1", + directory: projectDir, + time: { created: Date.now(), updated: Date.now() }, + }, + ], + filterSessionsByDate: async (sessionIDs) => sessionIDs, + formatSessionList: async (sessionIDs) => `sessions:${sessionIDs.join(",")}`, + sessionExists: async (sessionID) => sessionID === "ses_test123", + readSessionMessages: async (sessionID): Promise => + sessionID === "ses_test123" + ? [{ + id: `${sessionID}-msg`, + role: "user", + time: { created: Date.now() }, + parts: [{ id: `${sessionID}-part`, type: "text", text: "hello" }], + }] + : [], + readSessionTodos: async (): Promise => [], + formatSessionMessages: (messages) => `messages:${messages.length}`, + getAllSessions: async () => ["ses_test123", "ses_test456"], + searchInSession: async (sessionID): Promise => [ + { + session_id: sessionID, + message_id: `${sessionID}-msg`, + excerpt: "test snippet", + role: "user", + match_count: 1, + }, + ], + formatSearchResults: (results) => `results:${results.length}`, + getSessionInfo: async (sessionID): Promise => + sessionID === "ses_test123" + ? { + id: sessionID, + message_count: 1, + first_message: new Date(), + last_message: new Date(), + agents_used: ["test-agent"], + has_todos: false, + has_transcript: false, + todos: [], + transcript_entries: 0, + } + : null, + formatSessionInfo: (info) => `info:${info.id}`, + }) +} describe("session-manager tools", () => { test("session_list executes without error", async () => { + const { session_list } = createTestTools() const result = await session_list.execute({}, mockContext) expect(typeof result).toBe("string") }) test("session_list respects limit parameter", async () => { + const { session_list } = createTestTools() const result = await session_list.execute({ limit: 5 }, mockContext) expect(typeof result).toBe("string") }) test("session_list filters by date range", async () => { + const { session_list } = createTestTools() const result = await session_list.execute({ from_date: "2025-12-01T00:00:00Z", to_date: "2025-12-31T23:59:59Z", @@ -44,6 +105,7 @@ describe("session-manager tools", () => { }) test("session_list filters by project_path", async () => { + const { session_list } = createTestTools() //#given const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode" @@ -55,6 +117,7 @@ describe("session-manager tools", () => { }) test("session_list uses ctx.directory as default project_path", async () => { + const { session_list } = createTestTools() //#given - no project_path provided //#when @@ -65,12 +128,14 @@ describe("session-manager tools", () => { }) test("session_read handles non-existent session", async () => { + const { session_read } = createTestTools() const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext) expect(result).toContain("not found") }) test("session_read executes with valid parameters", async () => { + const { session_read } = createTestTools() const result = await session_read.execute({ session_id: "ses_test123", include_todos: true, @@ -81,6 +146,7 @@ describe("session-manager tools", () => { }) test("session_read respects limit parameter", async () => { + const { session_read } = createTestTools() const result = await session_read.execute({ session_id: "ses_test123", limit: 10, @@ -90,12 +156,14 @@ describe("session-manager tools", () => { }) test("session_search executes without error", async () => { + const { session_search } = createTestTools() const result = await session_search.execute({ query: "test" }, mockContext) expect(typeof result).toBe("string") }) test("session_search filters by session_id", async () => { + const { session_search } = createTestTools() const result = await session_search.execute({ query: "test", session_id: "ses_test123", @@ -105,6 +173,7 @@ describe("session-manager tools", () => { }) test("session_search respects case_sensitive parameter", async () => { + const { session_search } = createTestTools() const result = await session_search.execute({ query: "TEST", case_sensitive: true, @@ -114,6 +183,7 @@ describe("session-manager tools", () => { }) test("session_search respects limit parameter", async () => { + const { session_search } = createTestTools() const result = await session_search.execute({ query: "test", limit: 5, @@ -123,12 +193,14 @@ describe("session-manager tools", () => { }) test("session_info handles non-existent session", async () => { + const { session_info } = createTestTools() const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext) expect(result).toContain("not found") }) test("session_info executes with valid session", async () => { + const { session_info } = createTestTools() const result = await session_info.execute({ session_id: "ses_test123" }, mockContext) expect(typeof result).toBe("string") diff --git a/src/tools/skill/async-description-refresh.test.ts b/src/tools/skill/async-description-refresh.test.ts index 9712c2678..958457b6b 100644 --- a/src/tools/skill/async-description-refresh.test.ts +++ b/src/tools/skill/async-description-refresh.test.ts @@ -1,9 +1,20 @@ /// import { describe, expect, it } from "bun:test" -import { createSkillTool } from "./tools" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" +function requireFresh(modulePath: string): T { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } + return require(modulePath) as T +} + +function createSkillTool(...args: Parameters): ReturnType { + return requireFresh("./tools").createSkillTool(...args) +} + function createMockSkill(name: string): LoadedSkill { return { name, diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index 21b74e899..e82cd7653 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { discoverCommandsSync } from "./command-discovery" + +function requireFresh(modulePath: string): T { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } + return require(modulePath) as T +} + +function discoverCommandsSync(...args: Parameters): ReturnType { + return requireFresh("./command-discovery").discoverCommandsSync(...args) +} const ENV_KEYS = [ "CLAUDE_CONFIG_DIR", diff --git a/src/tools/slashcommand/execution-compatibility.test.ts b/src/tools/slashcommand/execution-compatibility.test.ts index c23fbe1a9..6d63bd678 100644 --- a/src/tools/slashcommand/execution-compatibility.test.ts +++ b/src/tools/slashcommand/execution-compatibility.test.ts @@ -2,8 +2,22 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { executeSlashCommand } from "../../hooks/auto-slash-command/executor" -import { discoverCommandsSync } from "./command-discovery" + +function requireFresh(modulePath: string): T { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } + return require(modulePath) as T +} + +function executeSlashCommand(...args: Parameters): ReturnType { + return requireFresh("../../hooks/auto-slash-command/executor").executeSlashCommand(...args) +} + +function discoverCommandsSync(...args: Parameters): ReturnType { + return requireFresh("./command-discovery").discoverCommandsSync(...args) +} describe("slashcommand discovery and execution compatibility", () => { let tempDir = "" diff --git a/src/tools/slashcommand/opencode-project-command-discovery.test.ts b/src/tools/slashcommand/opencode-project-command-discovery.test.ts index 3192564bd..0a7b41615 100644 --- a/src/tools/slashcommand/opencode-project-command-discovery.test.ts +++ b/src/tools/slashcommand/opencode-project-command-discovery.test.ts @@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { discoverCommandsSync } from "./command-discovery" + +function requireFresh(modulePath: string): T { + const resolvedPath = require.resolve(modulePath) + if (require.cache?.[resolvedPath]) { + delete require.cache[resolvedPath] + } + return require(modulePath) as T +} + +function discoverCommandsSync(...args: Parameters): ReturnType { + return requireFresh("./command-discovery").discoverCommandsSync(...args) +} function writeCommand(path: string, description: string, body: string): void { mkdirSync(join(path, ".."), { recursive: true })