diff --git a/src/hooks/atlas/tool-execute-after-background-launch.test.ts b/src/hooks/atlas/tool-execute-after-background-launch.test.ts index d7c56fdb3..9ed37f73c 100644 --- a/src/hooks/atlas/tool-execute-after-background-launch.test.ts +++ b/src/hooks/atlas/tool-execute-after-background-launch.test.ts @@ -8,9 +8,11 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createOpencodeClient, type Project } from "@opencode-ai/sdk" const isCallerOrchestratorMock = mock(async () => true) -const collectGitDiffStatsMock = mock(() => { - throw new Error("background launches should not trigger verification") -}) +const collectGitDiffStatsMock = mock(() => ({ + filesChanged: 0, + insertions: 0, + deletions: 0, +})) mock.module("../../shared/session-utils", () => ({ isCallerOrchestrator: isCallerOrchestratorMock, diff --git a/src/hooks/legacy-plugin-toast/auto-migrate.test.ts b/src/hooks/legacy-plugin-toast/auto-migrate.test.ts index cc8be7497..0ee33cb8c 100644 --- a/src/hooks/legacy-plugin-toast/auto-migrate.test.ts +++ b/src/hooks/legacy-plugin-toast/auto-migrate.test.ts @@ -1,5 +1,3 @@ -/// - import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -120,37 +118,6 @@ describe("autoMigrateLegacyPluginEntry", () => { }) }) - describe("#given opencode.jsonc contains a nested plugin key before the top-level plugin array", () => { - it("#then rewrites only the top-level plugin array", async () => { - // given - writeFileSync( - join(testConfigDir, "opencode.jsonc"), - `{ - "nested": { - "plugin": ["oh-my-opencode"] - }, - "plugin": ["oh-my-opencode@latest"] -} -`, - ) - - const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule() - - // when - const result = autoMigrateLegacyPluginEntry(testConfigDir) - - // then - expect(result.migrated).toBe(true) - const content = readFileSync(join(testConfigDir, "opencode.jsonc"), "utf-8") - expect(content).toContain(`"nested": { - "plugin": ["oh-my-opencode"] - }`) - expect(content).toContain(`"plugin": [ - "oh-my-openagent@latest" - ]`) - }) - }) - describe("#given only canonical entry exists", () => { it("#then returns migrated false and leaves file untouched", async () => { // given diff --git a/src/hooks/legacy-plugin-toast/auto-migrate.ts b/src/hooks/legacy-plugin-toast/auto-migrate.ts index f1ce1090a..34bc4bbc0 100644 --- a/src/hooks/legacy-plugin-toast/auto-migrate.ts +++ b/src/hooks/legacy-plugin-toast/auto-migrate.ts @@ -1,8 +1,7 @@ -import { existsSync, readFileSync } from "node:fs" +import { existsSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" import { parseJsoncSafe } from "../../shared/jsonc-parser" -import { migrateLegacyPluginEntry } from "../../shared/migrate-legacy-plugin-entry" import { getOpenCodeConfigPaths } from "../../shared/opencode-config-dir" import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared/plugin-identity" @@ -21,6 +20,10 @@ function isLegacyEntry(entry: string): boolean { return entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`) } +function isCanonicalEntry(entry: string): boolean { + return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`) +} + function toLegacyCanonical(entry: string): string { if (entry === LEGACY_PLUGIN_NAME) return PLUGIN_NAME if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) { @@ -57,13 +60,29 @@ export function autoMigrateLegacyPluginEntry(overrideConfigDir?: string): Migrat const legacyEntries = plugins.filter(isLegacyEntry) if (legacyEntries.length === 0) return { migrated: false, from: null, to: null, configPath } + const hasCanonical = plugins.some(isCanonicalEntry) const from = legacyEntries[0] const to = toLegacyCanonical(from) - if (!migrateLegacyPluginEntry(configPath)) { - return { migrated: false, from: null, to: null, configPath } + const normalized = hasCanonical + ? plugins.filter((p) => !isLegacyEntry(p)) + : plugins.map((p) => (isLegacyEntry(p) ? toLegacyCanonical(p) : p)) + + const isJsonc = configPath.endsWith(".jsonc") + if (isJsonc) { + const pluginArrayRegex = /((?:"plugin"|plugin)\s*:\s*)\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + if (match) { + const formattedPlugins = normalized.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `$1[\n ${formattedPlugins}\n ]`) + writeFileSync(configPath, newContent) + return { migrated: true, from, to, configPath } + } } + const parsed = JSON.parse(content) as Record + parsed.plugin = normalized + writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n") return { migrated: true, from, to, configPath } } catch { return { migrated: false, from: null, to: null, configPath } diff --git a/src/hooks/legacy-plugin-toast/hook.test.ts b/src/hooks/legacy-plugin-toast/hook.test.ts index d71d0d9f1..490908429 100644 --- a/src/hooks/legacy-plugin-toast/hook.test.ts +++ b/src/hooks/legacy-plugin-toast/hook.test.ts @@ -1,15 +1,10 @@ -/// - import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" - -import type { LegacyPluginCheckResult } from "../../shared/legacy-plugin-warning" import type { MigrationResult } from "./auto-migrate" -const mockCheckForLegacyPluginEntry = mock((): LegacyPluginCheckResult => ({ +const mockCheckForLegacyPluginEntry = mock(() => ({ hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [] as string[], - configPath: null, })) const mockAutoMigrate = mock((): MigrationResult => ({ @@ -72,7 +67,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: false, hasCanonicalEntry: true, legacyEntries: [], - configPath: null, }) mockAutoMigrate.mockReturnValue({ migrated: false, from: null, to: null, configPath: null }) mockShowToast.mockResolvedValue(undefined) @@ -99,7 +93,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: true, hasCanonicalEntry: false, legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/opencode.json", }) mockAutoMigrate.mockReturnValue({ migrated: true, @@ -127,7 +120,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: true, hasCanonicalEntry: false, legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/opencode.json", }) mockAutoMigrate.mockReturnValue({ migrated: false, @@ -155,7 +147,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: true, hasCanonicalEntry: false, legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/opencode.json", }) mockAutoMigrate.mockReturnValue({ migrated: true, @@ -182,7 +173,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: true, hasCanonicalEntry: false, legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/opencode.json", }) const { createLegacyPluginToastHook } = await importFreshModule() const hook = createLegacyPluginToastHook(createMockCtx()) @@ -202,7 +192,6 @@ describe("createLegacyPluginToastHook", () => { hasLegacyEntry: true, hasCanonicalEntry: false, legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/opencode.json", }) const { createLegacyPluginToastHook } = await importFreshModule() const hook = createLegacyPluginToastHook(createMockCtx()) @@ -214,25 +203,4 @@ describe("createLegacyPluginToastHook", () => { expect(mockCheckForLegacyPluginEntry).not.toHaveBeenCalled() }) }) - - describe("#given a project directory is available", () => { - it("#then passes the project directory into legacy config detection", async () => { - // given - mockCheckForLegacyPluginEntry.mockReturnValue({ - hasLegacyEntry: true, - hasCanonicalEntry: false, - legacyEntries: ["oh-my-opencode"], - configPath: "/tmp/test/.opencode/opencode.json", - }) - const { createLegacyPluginToastHook } = await importFreshModule() - const hook = createLegacyPluginToastHook(createMockCtx()) - - // when - await hook.event(createEvent("session.created")) - - // then - expect(mockCheckForLegacyPluginEntry).toHaveBeenCalledWith(undefined, "/tmp/test") - expect(mockAutoMigrate).toHaveBeenCalledWith("/tmp/test/.opencode") - }) - }) }) diff --git a/src/hooks/legacy-plugin-toast/hook.ts b/src/hooks/legacy-plugin-toast/hook.ts index 89b086a8a..4d6f55918 100644 --- a/src/hooks/legacy-plugin-toast/hook.ts +++ b/src/hooks/legacy-plugin-toast/hook.ts @@ -1,5 +1,3 @@ -import { dirname } from "node:path" - import type { PluginInput } from "@opencode-ai/plugin" import { checkForLegacyPluginEntry } from "../../shared/legacy-plugin-warning" @@ -19,10 +17,10 @@ export function createLegacyPluginToastHook(ctx: PluginInput) { fired = true - const result = checkForLegacyPluginEntry(undefined, ctx.directory) + const result = checkForLegacyPluginEntry() if (!result.hasLegacyEntry) return - const migration = autoMigrateLegacyPluginEntry(result.configPath ? dirname(result.configPath) : undefined) + const migration = autoMigrateLegacyPluginEntry() if (migration.migrated) { log("[legacy-plugin-toast] Auto-migrated opencode.json plugin entry", { diff --git a/src/shared/legacy-plugin-warning.test.ts b/src/shared/legacy-plugin-warning.test.ts index 11cef173d..9d114f9db 100644 --- a/src/shared/legacy-plugin-warning.test.ts +++ b/src/shared/legacy-plugin-warning.test.ts @@ -1,5 +1,3 @@ -/// - import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -82,26 +80,4 @@ describe("checkForLegacyPluginEntry", () => { expect(result.legacyEntries).toEqual([]) expect(result.configPath).toBeNull() }) - - describe("#given a project-local .opencode config contains a legacy plugin entry", () => { - it("#then detects the project-local config path", () => { - // given - const projectDir = join(testConfigDir, "project") - const projectConfigDir = join(projectDir, ".opencode") - mkdirSync(projectConfigDir, { recursive: true }) - writeFileSync( - join(projectConfigDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2), - ) - - // when - const result = checkForLegacyPluginEntry(undefined, projectDir) - - // then - expect(result.hasLegacyEntry).toBe(true) - expect(result.hasCanonicalEntry).toBe(false) - expect(result.legacyEntries).toEqual(["oh-my-opencode"]) - expect(result.configPath).toBe(join(projectConfigDir, "opencode.json")) - }) - }) }) diff --git a/src/shared/legacy-plugin-warning.ts b/src/shared/legacy-plugin-warning.ts index 28fdf624e..6ab2a77ef 100644 --- a/src/shared/legacy-plugin-warning.ts +++ b/src/shared/legacy-plugin-warning.ts @@ -16,36 +16,20 @@ export interface LegacyPluginCheckResult { configPath: string | null } -function getConfigPathFromDirectory(configDir: string): string | null { - const jsonPath = join(configDir, "opencode.json") - const jsoncPath = join(configDir, "opencode.jsonc") - - if (existsSync(jsoncPath)) return jsoncPath - if (existsSync(jsonPath)) return jsonPath - return null -} - -function getOpenCodeConfigPathsToCheck(overrideConfigDir?: string, projectDir?: string): string[] { +function getOpenCodeConfigPath(overrideConfigDir?: string): string | null { if (overrideConfigDir) { - const overridePath = getConfigPathFromDirectory(overrideConfigDir) - return overridePath ? [overridePath] : [] - } - - const configPaths: string[] = [] - - if (projectDir) { - const projectConfigPath = getConfigPathFromDirectory(join(projectDir, ".opencode")) - if (projectConfigPath) { - configPaths.push(projectConfigPath) - } + const jsonPath = join(overrideConfigDir, "opencode.json") + const jsoncPath = join(overrideConfigDir, "opencode.jsonc") + if (existsSync(jsoncPath)) return jsoncPath + if (existsSync(jsonPath)) return jsonPath + return null } const { configJsonc, configJson } = getOpenCodeConfigPaths({ binary: "opencode", version: null }) - if (existsSync(configJsonc)) configPaths.push(configJsonc) - else if (existsSync(configJson)) configPaths.push(configJson) - - return configPaths + if (existsSync(configJsonc)) return configJsonc + if (existsSync(configJson)) return configJson + return null } function isLegacyPluginEntry(entry: string): boolean { @@ -56,51 +40,29 @@ function isCanonicalPluginEntry(entry: string): boolean { return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`) } -export function checkForLegacyPluginEntry( - overrideConfigDir?: string, - projectDir?: string, -): LegacyPluginCheckResult { - const configPaths = getOpenCodeConfigPathsToCheck(overrideConfigDir, projectDir) - if (configPaths.length === 0) { +export function checkForLegacyPluginEntry(overrideConfigDir?: string): LegacyPluginCheckResult { + const configPath = getOpenCodeConfigPath(overrideConfigDir) + if (!configPath) { return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath: null } } - let hasCanonicalEntry = false - let detectedConfigPath: string | null = null - - for (const configPath of configPaths) { - detectedConfigPath ??= configPath - - try { - const content = readFileSync(configPath, "utf-8") - const parseResult = parseJsoncSafe(content) - if (!parseResult.data) { - continue - } - - const pluginEntries = parseResult.data.plugin ?? [] - const legacyEntries = pluginEntries.filter(isLegacyPluginEntry) - const fileHasCanonicalEntry = pluginEntries.some(isCanonicalPluginEntry) - - if (legacyEntries.length > 0) { - return { - hasLegacyEntry: true, - hasCanonicalEntry: fileHasCanonicalEntry, - legacyEntries, - configPath, - } - } - - hasCanonicalEntry ||= fileHasCanonicalEntry - } catch { - continue + try { + const content = readFileSync(configPath, "utf-8") + const parseResult = parseJsoncSafe(content) + if (!parseResult.data) { + return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath } } - } - return { - hasLegacyEntry: false, - hasCanonicalEntry, - legacyEntries: [], - configPath: detectedConfigPath, + const legacyEntries = (parseResult.data.plugin ?? []).filter(isLegacyPluginEntry) + const hasCanonicalEntry = (parseResult.data.plugin ?? []).some(isCanonicalPluginEntry) + + return { + hasLegacyEntry: legacyEntries.length > 0, + hasCanonicalEntry, + legacyEntries, + configPath, + } + } catch { + return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath: null } } } diff --git a/src/tools/delegate-task/task-schema.test.ts b/src/tools/delegate-task/task-schema.test.ts index 00be7fc43..2f3485195 100644 --- a/src/tools/delegate-task/task-schema.test.ts +++ b/src/tools/delegate-task/task-schema.test.ts @@ -3,7 +3,7 @@ const { describe, expect, test } = require("bun:test") import { createDelegateTask } from "./tools" describe("createDelegateTask schema", () => { - test("#given category arg #when tool is created #then category is constrained to available enum values", () => { + test("#given category arg #when tool is created #then category accepts any string", () => { //#given const toolDefinition = createDelegateTask({ manager: {} as never, client: {} as never, directory: "/tmp/test" }) @@ -13,16 +13,12 @@ import { createDelegateTask } from "./tools" type: string innerType: { def: { type: string } - options: string[] } } } //#then expect(categorySchema.def.type).toBe("optional") - expect(categorySchema.def.innerType.def.type).toBe("enum") - expect(categorySchema.def.innerType.options).toContain("quick") - expect(categorySchema.def.innerType.options).toContain("deep") - expect(categorySchema.def.innerType.options).toContain("ultrabrain") + expect(categorySchema.def.innerType.def.type).toBe("string") }) }) diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 929b5c2f9..c149f8025 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini - category: For task delegation (uses Sisyphus-Junior with category-optimized model) - subagent_type: For direct agent invocation (explore, librarian, oracle, etc.) - **DO NOT provide both.** category and subagent_type are mutually exclusive. + **DO NOT provide both.** If category is provided, subagent_type is ignored. - load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks. - category: Use predefined category → Spawns Sisyphus-Junior with category config Available categories: ${categoryList} - - subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus) + - subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus) - run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries. - session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity. - command: The command that triggered this task (optional, for slash command tracking). @@ -101,19 +101,21 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini description: tool.schema.string().describe("Short task description (3-5 words)"), prompt: tool.schema.string().describe("Full detailed prompt for the agent"), run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."), - category: tool.schema.enum(categoryNames).optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`), - subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."), + category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`), + subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."), session_id: tool.schema.string().optional().describe("Existing Task session to continue"), command: tool.schema.string().optional().describe("The command that triggered this task"), }, async execute(args: DelegateTaskArgs, toolContext) { const ctx = toolContext as ToolContextWithMetadata - let categoryOverrideNote: string | undefined - if (args.category && args.subagent_type) { - categoryOverrideNote = `[Note: You provided both category="${args.category}" and subagent_type="${args.subagent_type}". category takes precedence \u2014 subagent_type was ignored. Next time, provide ONLY category.]` - } if (args.category) { + if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) { + log("[task] category provided - overriding subagent_type to sisyphus-junior", { + category: args.category, + subagent_type: args.subagent_type, + }) + } args.subagent_type = SISYPHUS_JUNIOR_AGENT } await ctx.metadata?.({ @@ -221,8 +223,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini availableCategories, availableSkills, }) - const result = await executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel) - return categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result + return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel) } } else { const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples) @@ -245,13 +246,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini availableSkills, }) - const prependNote = (result: string) => categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result - if (runInBackground) { - return prependNote(await executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)) + return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain) } - return prependNote(await executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)) + return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain) }, }) }