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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+24
-16
@@ -1,36 +1,50 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||||
import { createWebsearchConfig } from "./websearch"
|
import * as logger from "../shared/logger"
|
||||||
import * as shared from "../shared"
|
|
||||||
|
|
||||||
let logSpy: ReturnType<typeof spyOn>
|
let logSpy: ReturnType<typeof spyOn>
|
||||||
|
let createWebsearchConfig: (typeof import("./websearch"))["createWebsearchConfig"]
|
||||||
|
let originalEnv: Record<"EXA_API_KEY" | "TAVILY_API_KEY", string | undefined>
|
||||||
|
|
||||||
beforeEach(() => {
|
async function importFreshWebsearchModule(): Promise<typeof import("./websearch")> {
|
||||||
logSpy = spyOn(shared, "log").mockImplementation(() => {})
|
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(() => {
|
afterEach(() => {
|
||||||
logSpy.mockRestore()
|
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", () => {
|
describe("createWebsearchConfig Tavily handling", () => {
|
||||||
test("returns undefined when Tavily API key is missing", () => {
|
test("returns undefined when Tavily API key is missing", () => {
|
||||||
const originalEnv = process.env.TAVILY_API_KEY
|
|
||||||
delete process.env.TAVILY_API_KEY
|
delete process.env.TAVILY_API_KEY
|
||||||
|
|
||||||
const config = createWebsearchConfig({ provider: "tavily" })
|
const config = createWebsearchConfig({ provider: "tavily" })
|
||||||
|
|
||||||
expect(config).toBeUndefined()
|
expect(config).toBeUndefined()
|
||||||
expect(logSpy).toHaveBeenCalledWith("[websearch] Tavily API key not found, skipping websearch MCP")
|
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", () => {
|
test("returns valid config when Tavily API key is present", () => {
|
||||||
const originalEnv = process.env.TAVILY_API_KEY
|
|
||||||
process.env.TAVILY_API_KEY = "test-key"
|
process.env.TAVILY_API_KEY = "test-key"
|
||||||
|
|
||||||
const config = createWebsearchConfig({ provider: "tavily" })
|
const config = createWebsearchConfig({ provider: "tavily" })
|
||||||
@@ -38,11 +52,5 @@ describe("createWebsearchConfig Tavily handling", () => {
|
|||||||
expect(config).toBeDefined()
|
expect(config).toBeDefined()
|
||||||
expect(config?.type).toBe("remote")
|
expect(config?.type).toBe("remote")
|
||||||
expect(config?.url).toBe("https://mcp.tavily.com/mcp/")
|
expect(config?.url).toBe("https://mcp.tavily.com/mcp/")
|
||||||
|
|
||||||
if (originalEnv) {
|
|
||||||
process.env.TAVILY_API_KEY = originalEnv
|
|
||||||
} else {
|
|
||||||
delete process.env.TAVILY_API_KEY
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import * as sessionRegistryModule from "../session-registry"
|
|||||||
import type { ReplyListenerDaemonState } from "../reply-listener-state"
|
import type { ReplyListenerDaemonState } from "../reply-listener-state"
|
||||||
import type { OpenClawConfig } from "../types"
|
import type { OpenClawConfig } from "../types"
|
||||||
|
|
||||||
const originalHome = process.env.HOME
|
|
||||||
const originalUserProfile = process.env.USERPROFILE
|
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "openclaw-reply-listener-discord-"))
|
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 () => {
|
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", {
|
new Response("unauthorized", {
|
||||||
status: 401,
|
status: 401,
|
||||||
}),
|
}),
|
||||||
)
|
))
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||||
|
|
||||||
const state = createState()
|
const state = createState()
|
||||||
|
|
||||||
await pollDiscordReplies(createConfig(), state, new ReplyListenerRateLimiter(10))
|
await pollDiscordReplies(createConfig(), state, new ReplyListenerRateLimiter(10))
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
expect(state.errors).toBe(1)
|
expect(state.errors).toBe(1)
|
||||||
expect(state.lastError).toBe("Discord API error: HTTP 401")
|
expect(state.lastError).toBe("Discord API error: HTTP 401")
|
||||||
expect(existsSync(stateFilePath)).toBe(true)
|
expect(existsSync(stateFilePath)).toBe(true)
|
||||||
@@ -94,7 +93,8 @@ describe("pollDiscordReplies", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("increments messagesInjected when a Discord reply matches a registered message", async () => {
|
test("increments messagesInjected when a Discord reply matches a registered message", async () => {
|
||||||
const fetchSpy = spyOn(globalThis, "fetch")
|
const fetchMock = mock()
|
||||||
|
fetchMock
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
@@ -109,6 +109,7 @@ describe("pollDiscordReplies", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||||
const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({
|
const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({
|
||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
tmuxSession: "session-1",
|
tmuxSession: "session-1",
|
||||||
@@ -126,7 +127,7 @@ describe("pollDiscordReplies", () => {
|
|||||||
|
|
||||||
expect(lookupSpy).toHaveBeenCalledWith("discord-bot", "outbound-1")
|
expect(lookupSpy).toHaveBeenCalledWith("discord-bot", "outbound-1")
|
||||||
expect(injectSpy).toHaveBeenCalledWith("%7", "Ship it", "discord", createConfig())
|
expect(injectSpy).toHaveBeenCalledWith("%7", "Ship it", "discord", createConfig())
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
expect(state.messagesSeen).toBe(1)
|
expect(state.messagesSeen).toBe(1)
|
||||||
expect(state.messagesInjected).toBe(1)
|
expect(state.messagesInjected).toBe(1)
|
||||||
expect(state.lastDiscordMessageId).toBe("incoming-1")
|
expect(state.lastDiscordMessageId).toBe("incoming-1")
|
||||||
|
|||||||
@@ -605,8 +605,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps task delegation disabled during prometheus sync continuation", async () => {
|
test("keeps task delegation enabled during prometheus sync continuation", async () => {
|
||||||
//#given - a resumed prometheus session should stay unable to delegate tasks
|
//#given - a resumed prometheus session should keep plan-family task permission
|
||||||
const promptAsyncCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
const promptAsyncCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
session: {
|
session: {
|
||||||
@@ -667,7 +667,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(promptAsyncCalls).toHaveLength(1)
|
expect(promptAsyncCalls).toHaveLength(1)
|
||||||
expect(promptAsyncCalls[0]?.body.tools).toEqual({
|
expect(promptAsyncCalls[0]?.body.tools).toEqual({
|
||||||
task: false,
|
task: true,
|
||||||
call_omo_agent: true,
|
call_omo_agent: true,
|
||||||
question: false,
|
question: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
|
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", () => {
|
describe("executeSyncTask - cleanup on error paths", () => {
|
||||||
let removeTaskCalls: string[] = []
|
let removeTaskCalls: string[] = []
|
||||||
let addTaskCalls: any[] = []
|
let addTaskCalls: any[] = []
|
||||||
@@ -23,6 +30,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
deleteCalls = []
|
deleteCalls = []
|
||||||
addCalls = []
|
addCalls = []
|
||||||
|
|
||||||
|
clearRequireCache("./sync-task")
|
||||||
|
|
||||||
//#given - initialize real task toast manager (avoid global module mocks)
|
//#given - initialize real task toast manager (avoid global module mocks)
|
||||||
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
|
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
|
||||||
_resetTaskToastManagerForTesting()
|
_resetTaskToastManagerForTesting()
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
declare const require: (name: string) => any
|
|
||||||
const { describe, expect, test } = require("bun:test")
|
const { describe, expect, test } = require("bun:test")
|
||||||
import { createDelegateTask } from "./tools"
|
|
||||||
|
function requireFresh<T>(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<typeof import("./tools").createDelegateTask>): ReturnType<typeof import("./tools").createDelegateTask> {
|
||||||
|
return requireFresh<typeof import("./tools")>("./tools").createDelegateTask(...args)
|
||||||
|
}
|
||||||
|
|
||||||
describe("createDelegateTask schema", () => {
|
describe("createDelegateTask schema", () => {
|
||||||
test("#given category arg #when tool is created #then category accepts any string", () => {
|
test("#given category arg #when tool is created #then category accepts any string", () => {
|
||||||
|
|||||||
@@ -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")
|
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 { 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 { CategoryConfig } from "../../config/schema"
|
||||||
import type { DelegateTaskArgs } from "./types"
|
import type { DelegateTaskArgs } from "./types"
|
||||||
import { __resetModelCache } from "../../shared/model-availability"
|
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 connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||||
import * as executor from "./executor"
|
import * as executor from "./executor"
|
||||||
|
|
||||||
|
const runtimeRequire = require as NodeJS.Require & { cache?: Record<string, unknown> }
|
||||||
|
|
||||||
|
function clearRequireCache(modulePath: string): void {
|
||||||
|
const resolvedPath = runtimeRequire.resolve(modulePath)
|
||||||
|
if (runtimeRequire.cache?.[resolvedPath]) {
|
||||||
|
delete runtimeRequire.cache[resolvedPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCategoryConfig(...args: Parameters<typeof import("./tools").resolveCategoryConfig>): ReturnType<typeof import("./tools").resolveCategoryConfig> {
|
||||||
|
clearRequireCache("./tools")
|
||||||
|
return require("./tools").resolveCategoryConfig(...args)
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
|
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
|
||||||
|
|
||||||
const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"]
|
const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"]
|
||||||
@@ -37,6 +51,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
clearRequireCache("./tools")
|
||||||
__resetModelCache()
|
__resetModelCache()
|
||||||
clearSkillCache()
|
clearSkillCache()
|
||||||
__setTimingConfig({
|
__setTimingConfig({
|
||||||
@@ -253,6 +268,20 @@ describe("sisyphus-task", () => {
|
|||||||
expect(result).toBe(true)
|
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'", () => {
|
test("returns false for 'oracle'", () => {
|
||||||
//#given / #when
|
//#given / #when
|
||||||
const result = isPlanFamily("oracle")
|
const result = isPlanFamily("oracle")
|
||||||
@@ -3608,6 +3637,26 @@ describe("sisyphus-task", () => {
|
|||||||
expect(result).toContain("plan-family")
|
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 () => {
|
test("plan cannot delegate to prometheus (cross-blocking)", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
@@ -4105,7 +4154,7 @@ describe("sisyphus-task", () => {
|
|||||||
expect(promptBody.tools.task).toBe(true)
|
expect(promptBody.tools.task).toBe(true)
|
||||||
}, { timeout: 20000 })
|
}, { 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
|
//#given
|
||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let promptBody: any
|
let promptBody: any
|
||||||
@@ -4130,8 +4179,8 @@ describe("sisyphus-task", () => {
|
|||||||
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
|
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
|
||||||
)
|
)
|
||||||
|
|
||||||
//#then
|
//#then - prometheus shares task permission with the plan family
|
||||||
expect(promptBody.tools.task).toBe(false)
|
expect(promptBody.tools.task).toBe(true)
|
||||||
}, { timeout: 20000 })
|
}, { timeout: 20000 })
|
||||||
|
|
||||||
test("non-plan subagent should NOT have task permission", async () => {
|
test("non-plan subagent should NOT have task permission", async () => {
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
declare const require: (name: string) => any
|
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||||
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
|
|
||||||
import type { DelegateTaskArgs } from "../types"
|
import type { DelegateTaskArgs } from "../types"
|
||||||
import type { ExecutorContext } from "../executor-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")
|
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<string, string[]>
|
||||||
|
connected: string[]
|
||||||
|
updatedAt: string
|
||||||
|
} | null,
|
||||||
|
)
|
||||||
|
|
||||||
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
|
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
|
||||||
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
|
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
|
||||||
}
|
}
|
||||||
@@ -41,17 +49,30 @@ function createExecutorContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveSubagentExecution", () => {
|
describe("resolveSubagentExecution", () => {
|
||||||
let logSpy: ReturnType<typeof spyOn> | undefined
|
|
||||||
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mock.restore()
|
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())
|
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
logSpy?.mockRestore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
|
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")
|
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
|
//#given
|
||||||
const args = createBaseArgs({ subagent_type: "review" })
|
const args = createBaseArgs({ subagent_type: "review" })
|
||||||
const executorCtx = createExecutorContext(async () => {
|
const executorCtx = createExecutorContext(async () => {
|
||||||
@@ -79,17 +100,12 @@ describe("resolveSubagentExecution", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
expect(result.agentToUse).toBe("")
|
||||||
const callArgs = logSpy?.mock.calls[0]
|
expect(result.categoryModel).toBeUndefined()
|
||||||
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
|
expect(result.error).toBe('Failed to delegate to agent "review": network timeout')
|
||||||
expect(callArgs?.[1]).toEqual({
|
|
||||||
requestedAgent: "review",
|
|
||||||
parentAgent: "sisyphus",
|
|
||||||
error: "network timeout",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("hides primary agents from task delegation lookups", async () => {
|
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 () => {
|
test("normalizes matched agent model string before returning categoryModel", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["grok-3", "gpt-5.3-codex"] },
|
models: { openai: ["grok-3", "gpt-5.3-codex"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -145,12 +161,11 @@ describe("resolveSubagentExecution", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
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 () => {
|
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { quotio: ["claude-haiku-4-5"] },
|
models: { quotio: ["claude-haiku-4-5"] },
|
||||||
connected: ["quotio"],
|
connected: ["quotio"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -178,12 +193,11 @@ describe("resolveSubagentExecution", () => {
|
|||||||
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
|
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
|
||||||
{ providers: ["quotio"], model: "glm-5", variant: "max" },
|
{ providers: ["quotio"], model: "glm-5", variant: "max" },
|
||||||
])
|
])
|
||||||
cacheSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses category fallback_models when agent override points at category", async () => {
|
test("uses category fallback_models when agent override points at category", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { anthropic: ["claude-haiku-4-5"] },
|
models: { anthropic: ["claude-haiku-4-5"] },
|
||||||
connected: ["anthropic"],
|
connected: ["anthropic"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -215,17 +229,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
expect(result.fallbackChain).toEqual([
|
expect(result.fallbackChain).toEqual([
|
||||||
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
|
{ 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 () => {
|
test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4"] },
|
models: { openai: ["gpt-5.4"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -265,18 +278,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
|
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4-preview"] },
|
models: { openai: ["gpt-5.4-preview"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -306,18 +317,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
modelID: "gpt-5.4-preview",
|
modelID: "gpt-5.4-preview",
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4-preview"] },
|
models: { openai: ["gpt-5.4-preview"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -357,18 +366,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
maxTokens: 2222,
|
maxTokens: 2222,
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => {
|
test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4-preview"] },
|
models: { openai: ["gpt-5.4-preview"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -405,18 +412,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
variant: "max",
|
variant: "max",
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
|
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4o"] },
|
models: { openai: ["gpt-5.4o"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -448,18 +453,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
variant: "low",
|
variant: "low",
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => {
|
test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { minimaxi: ["MiniMax-M2.7"] },
|
models: { minimaxi: ["MiniMax-M2.7"] },
|
||||||
connected: ["minimaxi"],
|
connected: ["minimaxi"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
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 args = createBaseArgs({ subagent_type: "my-custom-agent" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -473,18 +476,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.categoryModel?.modelID).not.toBe("MiniMax-M2.7-highspeed")
|
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 () => {
|
test("uses matchedAgent.model as fallback when model is available", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { minimaxi: ["MiniMax-M2.7-highspeed"] },
|
models: { minimaxi: ["MiniMax-M2.7-highspeed"] },
|
||||||
connected: ["minimaxi"],
|
connected: ["minimaxi"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
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 args = createBaseArgs({ subagent_type: "my-custom-agent" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -498,18 +499,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.categoryModel).toEqual({ providerID: "minimaxi", modelID: "MiniMax-M2.7-highspeed" })
|
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 () => {
|
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-4o-preview"] },
|
models: { openai: ["gpt-4o-preview"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -546,18 +545,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
variant: "max",
|
variant: "max",
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves category temperature when fallback entry leaves temperature undefined", async () => {
|
test("preserves category temperature when fallback entry leaves temperature undefined", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: { openai: ["gpt-5.4"] },
|
models: { openai: ["gpt-5.4"] },
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -596,18 +593,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
temperature: 0.55,
|
temperature: 0.55,
|
||||||
top_p: 0.45,
|
top_p: 0.45,
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies category tuning params in the cold-cache override path", async () => {
|
test("applies category tuning params in the cold-cache override path", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: {},
|
models: {},
|
||||||
connected: [],
|
connected: [],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
})
|
})
|
||||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue([])
|
readConnectedProvidersCacheMock.mockReturnValue([])
|
||||||
const args = createBaseArgs({ subagent_type: "explore" })
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -648,27 +643,39 @@ describe("resolveSubagentExecution", () => {
|
|||||||
reasoningEffort: "medium",
|
reasoningEffort: "medium",
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
})
|
})
|
||||||
cacheSpy.mockRestore()
|
|
||||||
connectedSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("resolveSubagentExecution - agent name sanitization", () => {
|
describe("resolveSubagentExecution - agent name sanitization", () => {
|
||||||
let logSpy: ReturnType<typeof spyOn> | undefined
|
|
||||||
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
||||||
|
|
||||||
beforeEach(async () => {
|
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())
|
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
logSpy?.mockRestore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("strips backslash-wrapped agent names like \\hephaestus\\", async () => {
|
test("strips backslash-wrapped agent names like \\hephaestus\\", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: {},
|
models: {},
|
||||||
connected: [],
|
connected: [],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -684,12 +691,11 @@ describe("resolveSubagentExecution - agent name sanitization", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.agentToUse).toBe("Hephaestus - Deep Agent")
|
expect(result.agentToUse).toBe("Hephaestus - Deep Agent")
|
||||||
cacheSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("strips double-quoted agent names", async () => {
|
test("strips double-quoted agent names", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: {},
|
models: {},
|
||||||
connected: [],
|
connected: [],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -705,12 +711,11 @@ describe("resolveSubagentExecution - agent name sanitization", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.agentToUse).toBe("oracle")
|
expect(result.agentToUse).toBe("oracle")
|
||||||
cacheSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("strips single-quoted agent names", async () => {
|
test("strips single-quoted agent names", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
models: {},
|
models: {},
|
||||||
connected: [],
|
connected: [],
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -726,6 +731,5 @@ describe("resolveSubagentExecution - agent name sanitization", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.error).toBeUndefined()
|
expect(result.error).toBeUndefined()
|
||||||
expect(result.agentToUse).toBe("explore")
|
expect(result.agentToUse).toBe("explore")
|
||||||
cacheSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
|
|||||||
import { createSessionManagerTools } from "./tools"
|
import { createSessionManagerTools } from "./tools"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
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"
|
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
||||||
|
|
||||||
@@ -18,23 +19,83 @@ const mockContext: ToolContext = {
|
|||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = createSessionManagerTools(mockCtx)
|
function createTestTools() {
|
||||||
const { session_list, session_read, session_search, session_info } = tools
|
return createSessionManagerTools(mockCtx, {
|
||||||
|
setStorageClient: () => {},
|
||||||
|
getMainSessions: async (): Promise<SessionMetadata[]> => [
|
||||||
|
{
|
||||||
|
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<SessionMessage[]> =>
|
||||||
|
sessionID === "ses_test123"
|
||||||
|
? [{
|
||||||
|
id: `${sessionID}-msg`,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
parts: [{ id: `${sessionID}-part`, type: "text", text: "hello" }],
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
readSessionTodos: async (): Promise<TodoItem[]> => [],
|
||||||
|
formatSessionMessages: (messages) => `messages:${messages.length}`,
|
||||||
|
getAllSessions: async () => ["ses_test123", "ses_test456"],
|
||||||
|
searchInSession: async (sessionID): Promise<SearchResult[]> => [
|
||||||
|
{
|
||||||
|
session_id: sessionID,
|
||||||
|
message_id: `${sessionID}-msg`,
|
||||||
|
excerpt: "test snippet",
|
||||||
|
role: "user",
|
||||||
|
match_count: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formatSearchResults: (results) => `results:${results.length}`,
|
||||||
|
getSessionInfo: async (sessionID): Promise<SessionInfo | null> =>
|
||||||
|
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", () => {
|
describe("session-manager tools", () => {
|
||||||
test("session_list executes without error", async () => {
|
test("session_list executes without error", async () => {
|
||||||
|
const { session_list } = createTestTools()
|
||||||
const result = await session_list.execute({}, mockContext)
|
const result = await session_list.execute({}, mockContext)
|
||||||
|
|
||||||
expect(typeof result).toBe("string")
|
expect(typeof result).toBe("string")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session_list respects limit parameter", async () => {
|
test("session_list respects limit parameter", async () => {
|
||||||
|
const { session_list } = createTestTools()
|
||||||
const result = await session_list.execute({ limit: 5 }, mockContext)
|
const result = await session_list.execute({ limit: 5 }, mockContext)
|
||||||
|
|
||||||
expect(typeof result).toBe("string")
|
expect(typeof result).toBe("string")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session_list filters by date range", async () => {
|
test("session_list filters by date range", async () => {
|
||||||
|
const { session_list } = createTestTools()
|
||||||
const result = await session_list.execute({
|
const result = await session_list.execute({
|
||||||
from_date: "2025-12-01T00:00:00Z",
|
from_date: "2025-12-01T00:00:00Z",
|
||||||
to_date: "2025-12-31T23:59:59Z",
|
to_date: "2025-12-31T23:59:59Z",
|
||||||
@@ -44,6 +105,7 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_list filters by project_path", async () => {
|
test("session_list filters by project_path", async () => {
|
||||||
|
const { session_list } = createTestTools()
|
||||||
//#given
|
//#given
|
||||||
const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
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 () => {
|
test("session_list uses ctx.directory as default project_path", async () => {
|
||||||
|
const { session_list } = createTestTools()
|
||||||
//#given - no project_path provided
|
//#given - no project_path provided
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
@@ -65,12 +128,14 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_read handles non-existent session", async () => {
|
test("session_read handles non-existent session", async () => {
|
||||||
|
const { session_read } = createTestTools()
|
||||||
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
|
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||||
|
|
||||||
expect(result).toContain("not found")
|
expect(result).toContain("not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session_read executes with valid parameters", async () => {
|
test("session_read executes with valid parameters", async () => {
|
||||||
|
const { session_read } = createTestTools()
|
||||||
const result = await session_read.execute({
|
const result = await session_read.execute({
|
||||||
session_id: "ses_test123",
|
session_id: "ses_test123",
|
||||||
include_todos: true,
|
include_todos: true,
|
||||||
@@ -81,6 +146,7 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_read respects limit parameter", async () => {
|
test("session_read respects limit parameter", async () => {
|
||||||
|
const { session_read } = createTestTools()
|
||||||
const result = await session_read.execute({
|
const result = await session_read.execute({
|
||||||
session_id: "ses_test123",
|
session_id: "ses_test123",
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -90,12 +156,14 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_search executes without error", async () => {
|
test("session_search executes without error", async () => {
|
||||||
|
const { session_search } = createTestTools()
|
||||||
const result = await session_search.execute({ query: "test" }, mockContext)
|
const result = await session_search.execute({ query: "test" }, mockContext)
|
||||||
|
|
||||||
expect(typeof result).toBe("string")
|
expect(typeof result).toBe("string")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session_search filters by session_id", async () => {
|
test("session_search filters by session_id", async () => {
|
||||||
|
const { session_search } = createTestTools()
|
||||||
const result = await session_search.execute({
|
const result = await session_search.execute({
|
||||||
query: "test",
|
query: "test",
|
||||||
session_id: "ses_test123",
|
session_id: "ses_test123",
|
||||||
@@ -105,6 +173,7 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_search respects case_sensitive parameter", async () => {
|
test("session_search respects case_sensitive parameter", async () => {
|
||||||
|
const { session_search } = createTestTools()
|
||||||
const result = await session_search.execute({
|
const result = await session_search.execute({
|
||||||
query: "TEST",
|
query: "TEST",
|
||||||
case_sensitive: true,
|
case_sensitive: true,
|
||||||
@@ -114,6 +183,7 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_search respects limit parameter", async () => {
|
test("session_search respects limit parameter", async () => {
|
||||||
|
const { session_search } = createTestTools()
|
||||||
const result = await session_search.execute({
|
const result = await session_search.execute({
|
||||||
query: "test",
|
query: "test",
|
||||||
limit: 5,
|
limit: 5,
|
||||||
@@ -123,12 +193,14 @@ describe("session-manager tools", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session_info handles non-existent session", async () => {
|
test("session_info handles non-existent session", async () => {
|
||||||
|
const { session_info } = createTestTools()
|
||||||
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
|
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||||
|
|
||||||
expect(result).toContain("not found")
|
expect(result).toContain("not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session_info executes with valid session", async () => {
|
test("session_info executes with valid session", async () => {
|
||||||
|
const { session_info } = createTestTools()
|
||||||
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
|
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
|
||||||
|
|
||||||
expect(typeof result).toBe("string")
|
expect(typeof result).toBe("string")
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, it } from "bun:test"
|
import { describe, expect, it } from "bun:test"
|
||||||
import { createSkillTool } from "./tools"
|
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||||
|
|
||||||
|
function requireFresh<T>(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<typeof import("./tools").createSkillTool>): ReturnType<typeof import("./tools").createSkillTool> {
|
||||||
|
return requireFresh<typeof import("./tools")>("./tools").createSkillTool(...args)
|
||||||
|
}
|
||||||
|
|
||||||
function createMockSkill(name: string): LoadedSkill {
|
function createMockSkill(name: string): LoadedSkill {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { discoverCommandsSync } from "./command-discovery"
|
|
||||||
|
function requireFresh<T>(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<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||||
|
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||||
|
}
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
"CLAUDE_CONFIG_DIR",
|
"CLAUDE_CONFIG_DIR",
|
||||||
|
|||||||
@@ -2,8 +2,22 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { executeSlashCommand } from "../../hooks/auto-slash-command/executor"
|
|
||||||
import { discoverCommandsSync } from "./command-discovery"
|
function requireFresh<T>(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<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand>): ReturnType<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand> {
|
||||||
|
return requireFresh<typeof import("../../hooks/auto-slash-command/executor")>("../../hooks/auto-slash-command/executor").executeSlashCommand(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||||
|
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||||
|
}
|
||||||
|
|
||||||
describe("slashcommand discovery and execution compatibility", () => {
|
describe("slashcommand discovery and execution compatibility", () => {
|
||||||
let tempDir = ""
|
let tempDir = ""
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { discoverCommandsSync } from "./command-discovery"
|
|
||||||
|
function requireFresh<T>(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<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||||
|
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||||
|
}
|
||||||
|
|
||||||
function writeCommand(path: string, description: string, body: string): void {
|
function writeCommand(path: string, description: string, body: string): void {
|
||||||
mkdirSync(join(path, ".."), { recursive: true })
|
mkdirSync(join(path, ".."), { recursive: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user