Merge branch 'code-yeongyu:dev' into fix/toolcall-format

This commit is contained in:
acamq
2026-03-02 08:41:37 -07:00
committed by GitHub
30 changed files with 328 additions and 1568 deletions
+6 -20
View File
@@ -242,28 +242,14 @@ describe("createBuiltinAgents with model overrides", () => {
test("createBuiltinAgents excludes disabled skills from availableSkills", async () => {
// #given
const disabledSkills = new Set(["playwright"])
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([
"anthropic/claude-opus-4-6",
"opencode/kimi-k2.5-free",
"zai-coding-plan/glm-5",
"opencode/big-pickle",
])
)
try {
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills)
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills)
// #then
expect(agents.sisyphus.prompt).not.toContain("playwright")
expect(agents.sisyphus.prompt).toContain("frontend-ui-ux")
expect(agents.sisyphus.prompt).toContain("git-master")
} finally {
cacheSpy.mockRestore()
fetchSpy.mockRestore()
}
// #then
expect(agents.sisyphus.prompt).not.toContain("playwright")
expect(agents.sisyphus.prompt).toContain("frontend-ui-ux")
expect(agents.sisyphus.prompt).toContain("git-master")
})
test("includes custom agents in orchestrator prompts when provided via config", async () => {
-14
View File
@@ -1,25 +1,11 @@
export {
OhMyOpenCodeConfigSchema,
AgentOverrideConfigSchema,
AgentOverridesSchema,
CustomAgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
OverridableAgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
} from "./schema"
export type {
OhMyOpenCodeConfig,
AgentOverrideConfig,
AgentOverrides,
CustomAgentOverrides,
McpName,
AgentName,
HookName,
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createOhMyOpenCodeJsonSchema } from "../../script/build-schema-document"
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
}
describe("schema document generation", () => {
test("custom_agents schema allows arbitrary custom agent keys with override shape", () => {
// given
const schema = createOhMyOpenCodeJsonSchema()
// when
const rootProperties = asRecord(schema.properties)
const agentsSchema = asRecord(rootProperties?.agents)
const customAgentsSchema = asRecord(rootProperties?.custom_agents)
const customPropertyNames = asRecord(customAgentsSchema?.propertyNames)
const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties)
const defs = asRecord(schema.$defs)
const sharedAgentOverrideSchema = asRecord(defs?.agentOverrideConfig)
const sharedAgentProperties = asRecord(sharedAgentOverrideSchema?.properties)
// then
expect(agentsSchema).toBeDefined()
expect(agentsSchema?.additionalProperties).toBeFalse()
expect(customAgentsSchema).toBeDefined()
expect(customPropertyNames?.pattern).toBeDefined()
expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]")
expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]")
expect(customAdditionalProperties).toBeDefined()
expect(customAdditionalProperties?.$ref).toBe("#/$defs/agentOverrideConfig")
expect(sharedAgentOverrideSchema).toBeDefined()
expect(sharedAgentProperties?.model).toEqual({ type: "string" })
expect(sharedAgentProperties?.temperature).toEqual(
expect.objectContaining({ type: "number" }),
)
})
})
-73
View File
@@ -530,79 +530,6 @@ describe("Sisyphus-Junior agent override", () => {
expect(result.data.agents?.momus?.category).toBe("quick")
}
})
test("schema accepts custom_agents override keys", () => {
// given
const config = {
custom_agents: {
translator: {
model: "google/gemini-3-flash-preview",
temperature: 0,
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview")
expect(result.data.custom_agents?.translator?.temperature).toBe(0)
}
})
test("schema rejects unknown keys under agents", () => {
// given
const config = {
agents: {
sisyphuss: {
model: "openai/gpt-5.3-codex",
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
test("schema rejects built-in agent names under custom_agents", () => {
// given
const config = {
custom_agents: {
sisyphus: {
model: "openai/gpt-5.3-codex",
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
test("schema rejects built-in agent names under custom_agents case-insensitively", () => {
// given
const config = {
custom_agents: {
Sisyphus: {
model: "openai/gpt-5.3-codex",
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
})
describe("BrowserAutomationProviderSchema", () => {
+2 -53
View File
@@ -1,6 +1,5 @@
import { z } from "zod"
import { FallbackModelsSchema } from "./fallback-models"
import { OverridableAgentNameSchema } from "./agent-names"
import { AgentPermissionSchema } from "./internal/permission"
export const AgentOverrideConfigSchema = z.object({
@@ -56,7 +55,7 @@ export const AgentOverrideConfigSchema = z.object({
.optional(),
})
const BuiltinAgentOverridesSchema = z.object({
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(),
@@ -73,57 +72,7 @@ const BuiltinAgentOverridesSchema = z.object({
explore: AgentOverrideConfigSchema.optional(),
"multimodal-looker": AgentOverrideConfigSchema.optional(),
atlas: AgentOverrideConfigSchema.optional(),
}).strict()
export const AgentOverridesSchema = BuiltinAgentOverridesSchema
const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options
const RESERVED_CUSTOM_AGENT_NAME_SET = new Set(
RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()),
)
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function toCaseInsensitiveLiteralPattern(value: string): string {
return value
.split("")
.map((char) => {
if (/^[A-Za-z]$/.test(char)) {
const lower = char.toLowerCase()
const upper = char.toUpperCase()
return `[${lower}${upper}]`
}
return escapeRegexLiteral(char)
})
.join("")
}
const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp(
`^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map(toCaseInsensitiveLiteralPattern).join("|")})$).+`,
)
export const CustomAgentOverridesSchema = z
.record(
z.string().regex(
RESERVED_CUSTOM_AGENT_NAME_PATTERN,
"custom_agents key cannot reuse built-in agent override name",
),
AgentOverrideConfigSchema,
)
.superRefine((value, ctx) => {
for (const key of Object.keys(value)) {
if (RESERVED_CUSTOM_AGENT_NAME_SET.has(key.toLowerCase())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: "custom_agents key cannot reuse built-in agent override name",
})
}
}
})
})
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type CustomAgentOverrides = z.infer<typeof CustomAgentOverridesSchema>
+1 -2
View File
@@ -1,7 +1,7 @@
import { z } from "zod"
import { AnyMcpNameSchema } from "../../mcp/types"
import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names"
import { AgentOverridesSchema, CustomAgentOverridesSchema } from "./agent-overrides"
import { AgentOverridesSchema } from "./agent-overrides"
import { BabysittingConfigSchema } from "./babysitting"
import { BackgroundTaskConfigSchema } from "./background-task"
import { BrowserAutomationConfigSchema } from "./browser-automation"
@@ -39,7 +39,6 @@ export const OhMyOpenCodeConfigSchema = z.object({
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(),
custom_agents: CustomAgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test"
import { parseSlashCommand } from "./detector"
describe("slash command parsing pattern", () => {
describe("#given plugin namespace includes dot", () => {
it("#then parses command name with dot and colon", () => {
// given
const text = "/my.plugin:run ship"
// when
const parsed = parseSlashCommand(text)
// then
expect(parsed).not.toBeNull()
expect(parsed?.command).toBe("my.plugin:run")
expect(parsed?.args).toBe("ship")
})
})
})
+1 -1
View File
@@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w:@/-]*)\s*(.*)/
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w.:@/-]*)\s*(.*)/
export const EXCLUDED_COMMANDS = new Set([
"ralph-loop",
@@ -35,6 +35,14 @@ function writePluginFixture(baseDir: string): void {
description: Run prompt from daplug
---
Execute daplug prompt flow.
`,
)
writeFileSync(
join(pluginInstallPath, "commands", "templated.md"),
`---
description: Templated prompt from daplug
---
Echo $ARGUMENTS and \${user_message}.
`,
)
@@ -165,4 +173,23 @@ describe("auto-slash command executor plugin dispatch", () => {
)
expect(result.error).not.toContain("Marketplace plugin commands")
})
it("replaces $ARGUMENTS placeholders in plugin command templates", async () => {
const result = await executeSlashCommand(
{
command: "daplug:templated",
args: "ship it",
raw: "/daplug:templated ship it",
},
{
skills: [],
pluginsEnabled: true,
},
)
expect(result.success).toBe(true)
expect(result.replacementText).toContain("Echo ship it and ship it.")
expect(result.replacementText).not.toContain("$ARGUMENTS")
expect(result.replacementText).not.toContain("${user_message}")
})
})
+7 -18
View File
@@ -7,16 +7,12 @@ import {
sanitizeModelField,
getClaudeConfigDir,
getOpenCodeConfigDir,
discoverPluginCommandDefinitions,
} from "../../shared"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
import {
discoverInstalledPlugins,
loadPluginCommands,
loadPluginSkillsAsCommands,
} from "../../features/claude-code-plugin-loader"
import type { ParsedSlashCommand } from "./types"
interface CommandScope {
@@ -109,18 +105,7 @@ export interface ExecutorOptions {
}
function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] {
if (options?.pluginsEnabled === false) {
return []
}
const { plugins } = discoverInstalledPlugins({
enabledPluginsOverride: options?.enabledPluginsOverride,
})
const pluginDefinitions = {
...loadPluginCommands(plugins),
...loadPluginSkillsAsCommands(plugins),
}
const pluginDefinitions = discoverPluginCommandDefinitions(options)
return Object.entries(pluginDefinitions).map(([name, definition]) => ({
name,
@@ -216,7 +201,11 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
const resolvedArguments = args
const substitutedContent = resolvedContent
.replace(/\$\{user_message\}/g, resolvedArguments)
.replace(/\$ARGUMENTS/g, resolvedArguments)
sections.push(substitutedContent.trim())
if (args) {
sections.push("\n\n---\n")
+2 -162
View File
@@ -1,10 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
detectLikelyBuiltinAgentTypos,
detectUnknownBuiltinAgentKeys,
mergeConfigs,
parseConfigPartially,
} from "./plugin-config";
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
import type { OhMyOpenCodeConfig } from "./config";
describe("mergeConfigs", () => {
@@ -120,27 +115,6 @@ describe("mergeConfigs", () => {
expect(result.disabled_hooks).toContain("session-recovery");
expect(result.disabled_hooks?.length).toBe(3);
});
it("should deep merge custom_agents", () => {
const base: OhMyOpenCodeConfig = {
custom_agents: {
translator: { model: "google/gemini-3-flash-preview" },
},
}
const override: OhMyOpenCodeConfig = {
custom_agents: {
translator: { temperature: 0 },
"database-architect": { model: "openai/gpt-5.3-codex" },
},
}
const result = mergeConfigs(base, override)
expect(result.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview")
expect(result.custom_agents?.translator?.temperature).toBe(0)
expect(result.custom_agents?.["database-architect"]?.model).toBe("openai/gpt-5.3-codex")
})
});
});
@@ -191,9 +165,7 @@ describe("parseConfigPartially", () => {
expect(result).not.toBeNull();
expect(result!.disabled_hooks).toEqual(["comment-checker"]);
expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2");
expect(result!.agents?.momus?.model).toBe("openai/gpt-5.2");
expect((result!.agents as Record<string, unknown>)?.prometheus).toBeUndefined();
expect(result!.agents).toBeUndefined();
});
it("should preserve valid agents when a non-agent section is invalid", () => {
@@ -210,36 +182,6 @@ describe("parseConfigPartially", () => {
expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2");
expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]);
});
it("should preserve valid built-in agent entries when agents contains unknown keys", () => {
const rawConfig = {
agents: {
sisyphus: { model: "openai/gpt-5.3-codex" },
sisyphuss: { model: "openai/gpt-5.3-codex" },
},
};
const result = parseConfigPartially(rawConfig);
expect(result).not.toBeNull();
expect(result!.agents?.sisyphus?.model).toBe("openai/gpt-5.3-codex");
expect((result!.agents as Record<string, unknown>)?.sisyphuss).toBeUndefined();
});
it("should preserve valid custom_agents entries when custom_agents contains reserved names", () => {
const rawConfig = {
custom_agents: {
translator: { model: "google/gemini-3-flash-preview" },
sisyphus: { model: "openai/gpt-5.3-codex" },
},
};
const result = parseConfigPartially(rawConfig);
expect(result).not.toBeNull();
expect(result!.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview");
expect((result!.custom_agents as Record<string, unknown>)?.sisyphus).toBeUndefined();
});
});
describe("completely invalid config", () => {
@@ -295,105 +237,3 @@ describe("parseConfigPartially", () => {
});
});
});
describe("detectLikelyBuiltinAgentTypos", () => {
it("detects near-miss builtin agent keys", () => {
const rawConfig = {
agents: {
sisyphuss: { model: "openai/gpt-5.2" },
},
}
const warnings = detectLikelyBuiltinAgentTypos(rawConfig)
expect(warnings).toEqual([
{
key: "sisyphuss",
suggestion: "sisyphus",
},
])
})
it("suggests canonical key casing for OpenCode-Builder typos", () => {
const rawConfig = {
agents: {
"opencode-buildr": { model: "openai/gpt-5.2" },
},
}
const warnings = detectLikelyBuiltinAgentTypos(rawConfig)
expect(warnings).toEqual([
{
key: "opencode-buildr",
suggestion: "OpenCode-Builder",
},
])
})
it("does not flag valid custom agent names", () => {
const rawConfig = {
agents: {
translator: { model: "google/gemini-3-flash-preview" },
},
}
const warnings = detectLikelyBuiltinAgentTypos(rawConfig)
expect(warnings).toEqual([])
})
})
describe("detectUnknownBuiltinAgentKeys", () => {
it("returns unknown keys under agents", () => {
const rawConfig = {
agents: {
sisyphus: { model: "openai/gpt-5.2" },
translator: { model: "google/gemini-3-flash-preview" },
},
}
const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig)
expect(unknownKeys).toEqual(["translator"])
})
it("returns empty array when all keys are built-ins", () => {
const rawConfig = {
agents: {
sisyphus: { model: "openai/gpt-5.2" },
prometheus: { model: "openai/gpt-5.2" },
},
}
const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig)
expect(unknownKeys).toEqual([])
})
it("excludes typo keys when explicitly provided", () => {
const rawConfig = {
agents: {
sisyphuss: { model: "openai/gpt-5.2" },
translator: { model: "google/gemini-3-flash-preview" },
},
}
const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"])
expect(unknownKeys).toEqual(["translator"])
})
it("excludes typo keys case-insensitively", () => {
const rawConfig = {
agents: {
Sisyphuss: { model: "openai/gpt-5.2" },
translator: { model: "google/gemini-3-flash-preview" },
},
}
const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"])
expect(unknownKeys).toEqual(["translator"])
})
})
+1 -162
View File
@@ -1,10 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import {
OhMyOpenCodeConfigSchema,
OverridableAgentNameSchema,
type OhMyOpenCodeConfig,
} from "./config";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import {
log,
deepMerge,
@@ -15,90 +11,6 @@ import {
migrateConfigFile,
} from "./shared";
const BUILTIN_AGENT_OVERRIDE_KEYS = OverridableAgentNameSchema.options;
const BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER = new Map(
BUILTIN_AGENT_OVERRIDE_KEYS.map((key) => [key.toLowerCase(), key]),
);
function levenshteinDistance(a: string, b: string): number {
const rows = a.length + 1;
const cols = b.length + 1;
const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
for (let i = 0; i < rows; i += 1) matrix[i][0] = i;
for (let j = 0; j < cols; j += 1) matrix[0][j] = j;
for (let i = 1; i < rows; i += 1) {
for (let j = 1; j < cols; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[rows - 1][cols - 1];
}
type AgentTypoWarning = {
key: string;
suggestion: string;
};
export function detectLikelyBuiltinAgentTypos(
rawConfig: Record<string, unknown>,
): AgentTypoWarning[] {
const agents = rawConfig.agents;
if (!agents || typeof agents !== "object") return [];
const warnings: AgentTypoWarning[] = [];
for (const key of Object.keys(agents)) {
const lowerKey = key.toLowerCase();
if (BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lowerKey)) {
continue;
}
let bestMatchLower: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const builtinKey of BUILTIN_AGENT_OVERRIDE_KEYS) {
const distance = levenshteinDistance(lowerKey, builtinKey.toLowerCase());
if (distance < bestDistance) {
bestDistance = distance;
bestMatchLower = builtinKey.toLowerCase();
}
}
if (bestMatchLower && bestDistance <= 2) {
const suggestion = BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.get(bestMatchLower) ?? bestMatchLower;
warnings.push({ key, suggestion });
}
}
return warnings;
}
export function detectUnknownBuiltinAgentKeys(
rawConfig: Record<string, unknown>,
excludeKeys: string[] = [],
): string[] {
const agents = rawConfig.agents;
if (!agents || typeof agents !== "object") return [];
const excluded = new Set(excludeKeys.map((key) => key.toLowerCase()));
return Object.keys(agents).filter(
(key) => {
const lower = key.toLowerCase();
return (
!BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lower)
&& !excluded.has(lower)
);
},
);
}
export function parseConfigPartially(
rawConfig: Record<string, unknown>
): OhMyOpenCodeConfig | null {
@@ -110,52 +22,7 @@ export function parseConfigPartially(
const partialConfig: Record<string, unknown> = {};
const invalidSections: string[] = [];
const parseAgentSectionEntries = (sectionKey: "agents" | "custom_agents"): void => {
const rawSection = rawConfig[sectionKey];
if (!rawSection || typeof rawSection !== "object") return;
const parsedSection: Record<string, unknown> = {};
const invalidEntries: string[] = [];
for (const [entryKey, entryValue] of Object.entries(rawSection)) {
const singleEntryResult = OhMyOpenCodeConfigSchema.safeParse({
[sectionKey]: { [entryKey]: entryValue },
});
if (singleEntryResult.success) {
const parsed = singleEntryResult.data as Record<string, unknown>;
const parsedSectionValue = parsed[sectionKey];
if (parsedSectionValue && typeof parsedSectionValue === "object") {
const typedSection = parsedSectionValue as Record<string, unknown>;
if (typedSection[entryKey] !== undefined) {
parsedSection[entryKey] = typedSection[entryKey];
}
}
continue;
}
const entryErrors = singleEntryResult.error.issues
.map((issue) => `${entryKey}: ${issue.message}`)
.join(", ");
if (entryErrors) {
invalidEntries.push(entryErrors);
}
}
if (Object.keys(parsedSection).length > 0) {
partialConfig[sectionKey] = parsedSection;
}
if (invalidEntries.length > 0) {
invalidSections.push(`${sectionKey}: ${invalidEntries.join(", ")}`);
}
};
for (const key of Object.keys(rawConfig)) {
if (key === "agents" || key === "custom_agents") {
parseAgentSectionEntries(key);
continue;
}
const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] });
if (sectionResult.success) {
const parsed = sectionResult.data as Record<string, unknown>;
@@ -191,32 +58,6 @@ export function loadConfigFromPath(
migrateConfigFile(configPath, rawConfig);
const typoWarnings = detectLikelyBuiltinAgentTypos(rawConfig);
if (typoWarnings.length > 0) {
const warningMsg = typoWarnings
.map((warning) => `agents.${warning.key} (did you mean agents.${warning.suggestion}?)`)
.join(", ");
log(`Potential agent override typos in ${configPath}: ${warningMsg}`);
addConfigLoadError({
path: configPath,
error: `Potential agent override typos detected: ${warningMsg}`,
});
}
const unknownAgentKeys = detectUnknownBuiltinAgentKeys(
rawConfig,
typoWarnings.map((warning) => warning.key),
);
if (unknownAgentKeys.length > 0) {
const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", ");
const migrationHint = "Move custom entries from agents.* to custom_agents.*";
log(`Unknown built-in agent override keys in ${configPath}: ${unknownKeysMsg}. ${migrationHint}`);
addConfigLoadError({
path: configPath,
error: `Unknown built-in agent override keys: ${unknownKeysMsg}. ${migrationHint}`,
});
}
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (result.success) {
@@ -257,7 +98,6 @@ export function mergeConfigs(
...base,
...override,
agents: deepMerge(base.agents, override.agents),
custom_agents: deepMerge(base.custom_agents, override.custom_agents),
categories: deepMerge(base.categories, override.categories),
disabled_agents: [
...new Set([
@@ -330,7 +170,6 @@ export function loadPluginConfig(
log("Final merged config", {
agents: config.agents,
custom_agents: config.custom_agents,
disabled_agents: config.disabled_agents,
disabled_mcps: config.disabled_mcps,
disabled_hooks: config.disabled_hooks,
+30 -82
View File
@@ -4,7 +4,6 @@ import type { OhMyOpenCodeConfig } from "../config";
import { log, migrateAgentConfig } from "../shared";
import { AGENT_NAME_MAP } from "../shared/migration";
import { getAgentDisplayName } from "../shared/agent-display-names";
import { mergeCategories } from "../shared/merge-categories";
import {
discoverConfigSourceSkills,
discoverOpencodeGlobalSkills,
@@ -18,13 +17,6 @@ import { reorderAgentsByPriority } from "./agent-priority-order";
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
import {
applyCustomAgentOverrides,
collectCustomAgentSummariesFromRecord,
mergeCustomAgentSummaries,
collectKnownCustomAgentNames,
filterSummariesByKnownNames,
} from "./custom-agent-utils";
type AgentConfigRecord = Record<string, Record<string, unknown> | undefined> & {
build?: Record<string, unknown>;
@@ -82,19 +74,26 @@ export async function applyAgentConfig(params: {
const browserProvider =
params.pluginConfig.browser_automation_engine?.provider ?? "playwright";
const currentModel = params.config.model as string | undefined;
const disabledAgentNames = new Set(
(migratedDisabledAgents ?? []).map((agent) => agent.toLowerCase()),
);
const filterDisabledAgents = (agents: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(agents).filter(
([name]) => !disabledAgentNames.has(name.toLowerCase()),
),
);
const disabledSkills = new Set<string>(params.pluginConfig.disabled_skills ?? []);
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false;
const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false;
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
params.pluginConfig.agents,
params.ctx.directory,
currentModel,
params.pluginConfig.categories,
params.pluginConfig.git_master,
allDiscoveredSkills,
params.ctx.client,
browserProvider,
currentModel,
disabledSkills,
useTaskSystem,
disableOmoEnv,
);
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;
const userAgents = includeClaudeAgents ? loadUserAgents() : {};
const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {};
@@ -107,49 +106,15 @@ export async function applyAgentConfig(params: {
]),
);
const configAgent = params.config.agent as AgentConfigRecord | undefined;
const filteredUserAgents = filterDisabledAgents(userAgents as Record<string, unknown>);
const filteredProjectAgents = filterDisabledAgents(projectAgents as Record<string, unknown>);
const filteredPluginAgents = filterDisabledAgents(pluginAgents as Record<string, unknown>);
const filteredConfigAgentsForSummary = filterDisabledAgents(
(configAgent as Record<string, unknown> | undefined) ?? {},
const disabledAgentNames = new Set(
(migratedDisabledAgents ?? []).map(a => a.toLowerCase())
);
const mergedCategories = mergeCategories(params.pluginConfig.categories)
const knownCustomAgentNames = collectKnownCustomAgentNames(
filteredUserAgents,
filteredProjectAgents,
filteredPluginAgents,
filteredConfigAgentsForSummary,
)
const customAgentSummaries = mergeCustomAgentSummaries(
collectCustomAgentSummariesFromRecord(filteredUserAgents),
collectCustomAgentSummariesFromRecord(filteredProjectAgents),
collectCustomAgentSummariesFromRecord(filteredPluginAgents),
collectCustomAgentSummariesFromRecord(filteredConfigAgentsForSummary),
filterSummariesByKnownNames(
collectCustomAgentSummariesFromRecord(
params.pluginConfig.custom_agents as Record<string, unknown> | undefined,
),
knownCustomAgentNames,
),
)
const filterDisabledAgents = (agents: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase()))
);
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
params.pluginConfig.agents,
params.ctx.directory,
currentModel,
params.pluginConfig.categories,
params.pluginConfig.git_master,
allDiscoveredSkills,
customAgentSummaries,
browserProvider,
currentModel,
disabledSkills,
useTaskSystem,
disableOmoEnv,
);
const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled =
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
@@ -158,6 +123,8 @@ export async function applyAgentConfig(params: {
const shouldDemotePlan = plannerEnabled && replacePlan;
const configuredDefaultAgent = getConfiguredDefaultAgent(params.config);
const configAgent = params.config.agent as AgentConfigRecord | undefined;
if (isSisyphusEnabled && builtinAgents.sisyphus) {
if (configuredDefaultAgent) {
(params.config as { default_agent?: string }).default_agent =
@@ -201,7 +168,6 @@ export async function applyAgentConfig(params: {
pluginPrometheusOverride: prometheusOverride,
userCategories: params.pluginConfig.categories,
currentModel,
customAgentSummaries,
});
}
@@ -237,9 +203,9 @@ export async function applyAgentConfig(params: {
...Object.fromEntries(
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
),
...filteredUserAgents,
...filteredProjectAgents,
...filteredPluginAgents,
...filterDisabledAgents(userAgents),
...filterDisabledAgents(projectAgents),
...filterDisabledAgents(pluginAgents),
...filteredConfigAgents,
build: { ...migratedBuild, mode: "subagent", hidden: true },
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
@@ -247,31 +213,13 @@ export async function applyAgentConfig(params: {
} else {
params.config.agent = {
...builtinAgents,
...filteredUserAgents,
...filteredProjectAgents,
...filteredPluginAgents,
...filterDisabledAgents(userAgents),
...filterDisabledAgents(projectAgents),
...filterDisabledAgents(pluginAgents),
...configAgent,
};
}
if (params.config.agent) {
const builtinOverrideKeys = new Set([
...Object.keys(builtinAgents).map((key) => key.toLowerCase()),
"build",
"plan",
"sisyphus-junior",
"opencode-builder",
])
applyCustomAgentOverrides({
mergedAgents: params.config.agent as Record<string, unknown>,
userOverrides: params.pluginConfig.custom_agents,
builtinOverrideKeys,
mergedCategories,
directory: params.ctx.directory,
})
}
if (params.config.agent) {
params.config.agent = remapAgentKeysToDisplayNames(
params.config.agent as Record<string, unknown>,
-341
View File
@@ -162,347 +162,6 @@ describe("Sisyphus-Junior model inheritance", () => {
})
})
describe("custom agent overrides", () => {
test("passes custom agent summaries into builtin agent prompt builder", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "Translate and localize text",
prompt: "Translate content",
},
})
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mock: { calls: unknown[][] }
}
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const firstCallArgs = createBuiltinAgentsMock.mock.calls[0]
expect(firstCallArgs).toBeDefined()
expect(Array.isArray(firstCallArgs[7])).toBe(true)
expect(firstCallArgs[7]).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "translator",
description: "Translate and localize text",
}),
]),
)
})
test("applies oh-my-opencode agent overrides to custom Claude agents", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "(user) translator",
prompt: "Base translator prompt",
},
})
const pluginConfig: OhMyOpenCodeConfig = {
custom_agents: {
translator: {
model: "google/gemini-3-flash-preview",
temperature: 0,
prompt_append: "Always preserve placeholders exactly.",
},
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentConfig = config.agent as Record<string, { model?: string; temperature?: number; prompt?: string }>
expect(agentConfig.translator).toBeDefined()
expect(agentConfig.translator.model).toBe("google/gemini-3-flash-preview")
expect(agentConfig.translator.temperature).toBe(0)
expect(agentConfig.translator.prompt).toContain("Base translator prompt")
expect(agentConfig.translator.prompt).toContain("Always preserve placeholders exactly.")
})
test("prometheus prompt includes custom agent catalog for planning", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "Translate and localize locale files",
prompt: "Translate content",
},
})
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentsConfig = config.agent as Record<string, { prompt?: string }>
const pKey = getAgentDisplayName("prometheus")
expect(agentsConfig[pKey]).toBeDefined()
expect(agentsConfig[pKey].prompt).toContain("<custom_agent_catalog>")
expect(agentsConfig[pKey].prompt).toContain("translator")
expect(agentsConfig[pKey].prompt).toContain("Translate and localize locale files")
})
test("prometheus prompt excludes unknown custom_agents entries", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "Translate and localize locale files",
prompt: "Translate content",
},
})
const pluginConfig: OhMyOpenCodeConfig = {
custom_agents: {
translator: {
description: "Translate and localize locale files",
},
ghostwriter: {
description: "This agent does not exist in runtime",
},
},
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentsConfig = config.agent as Record<string, { prompt?: string }>
const pKey = getAgentDisplayName("prometheus")
expect(agentsConfig[pKey]).toBeDefined()
expect(agentsConfig[pKey].prompt).toContain("translator")
expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter")
})
test("prometheus prompt excludes disabled custom agents from catalog", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "Translate and localize locale files",
prompt: "Translate content",
},
})
const pluginConfig: OhMyOpenCodeConfig = {
disabled_agents: ["translator"],
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentsConfig = config.agent as Record<string, { prompt?: string }>
const pKey = getAgentDisplayName("prometheus")
expect(agentsConfig[pKey]).toBeDefined()
expect(agentsConfig[pKey].prompt).not.toContain("translator")
})
test("prometheus custom prompt override still includes custom agent catalog", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "Translate and localize locale files",
prompt: "Translate content",
},
})
const pluginConfig: OhMyOpenCodeConfig = {
agents: {
prometheus: {
prompt: "Custom planner prompt",
},
},
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentsConfig = config.agent as Record<string, { prompt?: string }>
const pKey = getAgentDisplayName("prometheus")
expect(agentsConfig[pKey]).toBeDefined()
expect(agentsConfig[pKey].prompt).toContain("Custom planner prompt")
expect(agentsConfig[pKey].prompt).toContain("<custom_agent_catalog>")
expect(agentsConfig[pKey].prompt).toContain("translator")
})
test("custom agent summary merge preserves flags when custom_agents adds description", async () => {
// #given
;(agentLoader.loadUserAgents as any).mockReturnValue({
translator: {
name: "translator",
mode: "subagent",
description: "",
hidden: true,
disabled: true,
enabled: false,
prompt: "Translate content",
},
})
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mock: { calls: unknown[][] }
}
const pluginConfig: OhMyOpenCodeConfig = {
custom_agents: {
translator: {
description: "Translate and localize locale files",
},
},
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const firstCallArgs = createBuiltinAgentsMock.mock.calls[0]
const summaries = firstCallArgs[7] as Array<{
name: string
description: string
hidden?: boolean
disabled?: boolean
enabled?: boolean
}>
const translatorSummary = summaries.find((summary) => summary.name === "translator")
expect(translatorSummary).toBeDefined()
expect(translatorSummary?.description).toBe("Translate and localize locale files")
expect(translatorSummary?.hidden).toBe(true)
expect(translatorSummary?.disabled).toBe(true)
expect(translatorSummary?.enabled).toBe(false)
})
})
describe("Plan agent demote behavior", () => {
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
// #given
-142
View File
@@ -1,142 +0,0 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import { applyOverrides } from "../agents/builtin-agents/agent-overrides";
import type { AgentOverrideConfig } from "../agents/types";
import type { OhMyOpenCodeConfig } from "../config";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { AGENT_NAME_MAP } from "../shared/migration";
import { mergeCategories } from "../shared/merge-categories";
const RESERVED_AGENT_KEYS = new Set(
[
"build",
"plan",
"sisyphus-junior",
"opencode-builder",
...Object.keys(AGENT_NAME_MAP),
...Object.values(AGENT_NAME_MAP),
].map((key) => getAgentConfigKey(key).toLowerCase()),
);
export type AgentSummary = {
name: string;
description: string;
hidden?: boolean;
disabled?: boolean;
enabled?: boolean;
};
export function applyCustomAgentOverrides(params: {
mergedAgents: Record<string, unknown>;
userOverrides: OhMyOpenCodeConfig["custom_agents"] | undefined;
builtinOverrideKeys: Set<string>;
mergedCategories: ReturnType<typeof mergeCategories>;
directory: string;
}): void {
if (!params.userOverrides) return;
for (const [overrideKey, override] of Object.entries(params.userOverrides)) {
if (!override) continue;
const normalizedOverrideKey = getAgentConfigKey(overrideKey).toLowerCase();
if (params.builtinOverrideKeys.has(normalizedOverrideKey)) continue;
const existingKey = Object.keys(params.mergedAgents).find(
(key) => key.toLowerCase() === overrideKey.toLowerCase() || key.toLowerCase() === normalizedOverrideKey,
);
if (!existingKey) continue;
const existingAgent = params.mergedAgents[existingKey];
if (!existingAgent || typeof existingAgent !== "object") continue;
params.mergedAgents[existingKey] = applyOverrides(
existingAgent as AgentConfig,
override as AgentOverrideConfig,
params.mergedCategories,
params.directory,
);
}
}
export function collectCustomAgentSummariesFromRecord(
agents: Record<string, unknown> | undefined,
): AgentSummary[] {
if (!agents) return [];
const summaries: AgentSummary[] = [];
for (const [name, value] of Object.entries(agents)) {
const normalizedName = getAgentConfigKey(name).toLowerCase();
if (RESERVED_AGENT_KEYS.has(normalizedName)) continue;
if (!value || typeof value !== "object") continue;
const agentValue = value as Record<string, unknown>;
const description = typeof agentValue.description === "string" ? agentValue.description : "";
summaries.push({
name,
description,
hidden: typeof agentValue.hidden === "boolean" ? agentValue.hidden : undefined,
disabled: typeof agentValue.disabled === "boolean" ? agentValue.disabled : undefined,
enabled: typeof agentValue.enabled === "boolean" ? agentValue.enabled : undefined,
});
}
return summaries;
}
export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): AgentSummary[] {
const merged = new Map<string, AgentSummary>();
for (const group of summaryGroups) {
for (const summary of group) {
const key = summary.name.toLowerCase();
if (!merged.has(key)) {
merged.set(key, summary);
continue;
}
const existing = merged.get(key);
if (!existing) continue;
const existingDescription = existing.description.trim();
const incomingDescription = summary.description.trim();
merged.set(key, {
...existing,
...summary,
hidden: summary.hidden ?? existing.hidden,
disabled: summary.disabled ?? existing.disabled,
enabled: summary.enabled ?? existing.enabled,
description: incomingDescription || existingDescription,
});
}
}
return Array.from(merged.values());
}
export function collectKnownCustomAgentNames(
...agentGroups: Array<Record<string, unknown> | undefined>
): Set<string> {
const knownNames = new Set<string>();
for (const group of agentGroups) {
if (!group) continue;
for (const [name, value] of Object.entries(group)) {
const normalizedName = getAgentConfigKey(name).toLowerCase();
if (RESERVED_AGENT_KEYS.has(normalizedName)) continue;
if (!value || typeof value !== "object") continue;
knownNames.add(normalizedName);
}
}
return knownNames;
}
export function filterSummariesByKnownNames(
summaries: AgentSummary[],
knownNames: Set<string>,
): AgentSummary[] {
return summaries.filter((summary) => knownNames.has(summary.name.toLowerCase()));
}
@@ -1,7 +1,6 @@
import type { CategoryConfig } from "../config/schema";
import { PROMETHEUS_PERMISSION, getPrometheusPrompt } from "../agents/prometheus";
import { resolvePromptAppend } from "../agents/builtin-agents/resolve-file-uri";
import { parseRegisteredAgentSummaries } from "../agents/custom-agent-summaries";
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
import {
fetchAvailableModels,
@@ -28,7 +27,6 @@ export async function buildPrometheusAgentConfig(params: {
pluginPrometheusOverride: PrometheusOverride | undefined;
userCategories: Record<string, CategoryConfig> | undefined;
currentModel: string | undefined;
customAgentSummaries?: unknown;
}): Promise<Record<string, unknown>> {
const categoryConfig = params.pluginPrometheusOverride?.category
? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories)
@@ -67,18 +65,11 @@ export async function buildPrometheusAgentConfig(params: {
const maxTokensToUse =
params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens;
const customAgentCatalog = parseRegisteredAgentSummaries(params.customAgentSummaries)
const customAgentBlock = customAgentCatalog.length > 0
? `\n\n<custom_agent_catalog>\nAvailable custom agents for planning/delegation:\n${customAgentCatalog
.map((agent) => `- ${agent.name}: ${agent.description || "No description provided"}`)
.join("\n")}\n</custom_agent_catalog>`
: ""
const base: Record<string, unknown> = {
...(resolvedModel ? { model: resolvedModel } : {}),
...(variantToUse ? { variant: variantToUse } : {}),
mode: "all",
prompt: getPrometheusPrompt(resolvedModel) + customAgentBlock,
prompt: getPrometheusPrompt(resolvedModel),
permission: PROMETHEUS_PERMISSION,
description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
color: (params.configAgentPlan?.color as string) ?? "#FF5722",
@@ -103,12 +94,5 @@ export async function buildPrometheusAgentConfig(params: {
if (prompt_append && typeof merged.prompt === "string") {
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append);
}
if (
customAgentBlock
&& typeof merged.prompt === "string"
&& !merged.prompt.includes("<custom_agent_catalog>")
) {
merged.prompt = merged.prompt + customAgentBlock;
}
return merged;
}
@@ -50,28 +50,34 @@ describe("collectGitDiffStats", () => {
//#then
expect(execSyncSpy).not.toHaveBeenCalled()
expect(execFileSyncSpy).toHaveBeenCalledTimes(3)
expect(execFileSyncSpy.mock.calls.length).toBeGreaterThanOrEqual(3)
const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncSpy.mock
.calls[0]! as unknown as [string, string[], { cwd?: string }]
expect(firstCallFile).toBe("git")
expect(firstCallArgs).toEqual(["diff", "--numstat", "HEAD"])
expect(firstCallOpts.cwd).toBe(directory)
expect(firstCallArgs.join(" ")).not.toContain(directory)
const calls = execFileSyncSpy.mock.calls as unknown as Array<[string, string[], { cwd?: string }]>
const diffCall = calls.find(([, args]) => args[0] === "diff")
const statusCall = calls.find(([, args]) => args[0] === "status")
const untrackedCall = calls.find(([, args]) => args[0] === "ls-files")
const [secondCallFile, secondCallArgs, secondCallOpts] = execFileSyncSpy.mock
.calls[1]! as unknown as [string, string[], { cwd?: string }]
expect(secondCallFile).toBe("git")
expect(secondCallArgs).toEqual(["status", "--porcelain"])
expect(secondCallOpts.cwd).toBe(directory)
expect(secondCallArgs.join(" ")).not.toContain(directory)
expect(diffCall).toBeDefined()
expect(statusCall).toBeDefined()
expect(untrackedCall).toBeDefined()
const [thirdCallFile, thirdCallArgs, thirdCallOpts] = execFileSyncSpy.mock
.calls[2]! as unknown as [string, string[], { cwd?: string }]
expect(thirdCallFile).toBe("git")
expect(thirdCallArgs).toEqual(["ls-files", "--others", "--exclude-standard"])
expect(thirdCallOpts.cwd).toBe(directory)
expect(thirdCallArgs.join(" ")).not.toContain(directory)
const [diffCallFile, diffCallArgs, diffCallOpts] = diffCall!
expect(diffCallFile).toBe("git")
expect(diffCallArgs).toEqual(["diff", "--numstat", "HEAD"])
expect(diffCallOpts.cwd).toBe(directory)
expect(diffCallArgs.join(" ")).not.toContain(directory)
const [statusCallFile, statusCallArgs, statusCallOpts] = statusCall!
expect(statusCallFile).toBe("git")
expect(statusCallArgs).toEqual(["status", "--porcelain"])
expect(statusCallOpts.cwd).toBe(directory)
expect(statusCallArgs.join(" ")).not.toContain(directory)
const [untrackedCallFile, untrackedCallArgs, untrackedCallOpts] = untrackedCall!
expect(untrackedCallFile).toBe("git")
expect(untrackedCallArgs).toEqual(["ls-files", "--others", "--exclude-standard"])
expect(untrackedCallOpts.cwd).toBe(directory)
expect(untrackedCallArgs.join(" ")).not.toContain(directory)
expect(readFileSyncSpy).toHaveBeenCalled()
+1
View File
@@ -60,4 +60,5 @@ export * from "./normalize-sdk-response"
export * from "./session-directory-resolver"
export * from "./prompt-tools"
export * from "./internal-initiator-marker"
export * from "./plugin-command-discovery"
export { SessionCategoryRegistry } from "./session-category-registry"
+135
View File
@@ -0,0 +1,135 @@
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 { discoverPluginCommandDefinitions } from "./plugin-command-discovery"
const ENV_KEYS = [
"CLAUDE_CONFIG_DIR",
"CLAUDE_PLUGINS_HOME",
"CLAUDE_SETTINGS_PATH",
"OPENCODE_CONFIG_DIR",
] as const
type EnvKey = (typeof ENV_KEYS)[number]
type EnvSnapshot = Record<EnvKey, string | undefined>
function writePluginFixture(baseDir: string): void {
const claudeConfigDir = join(baseDir, "claude-config")
const pluginsHome = join(claudeConfigDir, "plugins")
const settingsPath = join(claudeConfigDir, "settings.json")
const opencodeConfigDir = join(baseDir, "opencode-config")
const pluginInstallPath = join(baseDir, "installed-plugins", "daplug")
const pluginKey = "daplug@1.0.0"
mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true })
mkdirSync(join(pluginInstallPath, "commands"), { recursive: true })
mkdirSync(join(pluginInstallPath, "skills", "plugin-plan"), { recursive: true })
writeFileSync(
join(pluginInstallPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2),
)
writeFileSync(
join(pluginInstallPath, "commands", "run-prompt.md"),
`---
description: Run prompt from daplug
---
Execute daplug prompt flow.
`,
)
writeFileSync(
join(pluginInstallPath, "skills", "plugin-plan", "SKILL.md"),
`---
name: plugin-plan
description: Plan work from daplug skill
---
Build a plan from plugin skill context.
`,
)
mkdirSync(pluginsHome, { recursive: true })
writeFileSync(
join(pluginsHome, "installed_plugins.json"),
JSON.stringify(
{
version: 2,
plugins: {
[pluginKey]: [
{
scope: "user",
installPath: pluginInstallPath,
version: "1.0.0",
installedAt: "2026-01-01T00:00:00.000Z",
lastUpdated: "2026-01-01T00:00:00.000Z",
},
],
},
},
null,
2,
),
)
mkdirSync(claudeConfigDir, { recursive: true })
writeFileSync(
settingsPath,
JSON.stringify(
{
enabledPlugins: {
[pluginKey]: true,
},
},
null,
2,
),
)
mkdirSync(opencodeConfigDir, { recursive: true })
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
process.env.CLAUDE_SETTINGS_PATH = settingsPath
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
}
describe("plugin command discovery utility", () => {
let tempDir = ""
let envSnapshot: EnvSnapshot
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-shared-plugin-discovery-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME,
CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
}
writePluginFixture(tempDir)
})
afterEach(() => {
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
delete process.env[key]
} else {
process.env[key] = previousValue
}
}
rmSync(tempDir, { recursive: true, force: true })
})
describe("#given plugin loading is enabled", () => {
it("#then returns plugin command and skill definitions", () => {
// given
const options = { pluginsEnabled: true }
// when
const definitions = discoverPluginCommandDefinitions(options)
// then
expect(Object.keys(definitions)).toContain("daplug:run-prompt")
expect(Object.keys(definitions)).toContain("daplug:plugin-plan")
})
})
})
+28
View File
@@ -0,0 +1,28 @@
import {
discoverInstalledPlugins,
loadPluginCommands,
loadPluginSkillsAsCommands,
} from "../features/claude-code-plugin-loader"
import type { CommandDefinition } from "../features/claude-code-command-loader/types"
export interface PluginCommandDiscoveryOptions {
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
}
export function discoverPluginCommandDefinitions(
options?: PluginCommandDiscoveryOptions,
): Record<string, CommandDefinition> {
if (options?.pluginsEnabled === false) {
return {}
}
const { plugins } = discoverInstalledPlugins({
enabledPluginsOverride: options?.enabledPluginsOverride,
})
return {
...loadPluginCommands(plugins),
...loadPluginSkillsAsCommands(plugins),
}
}
@@ -79,56 +79,4 @@ describe("resolveSubagentExecution", () => {
error: "network timeout",
})
})
test("uses inherited model for custom agents without explicit model", async () => {
//#given
const args = createBaseArgs({ subagent_type: "translator" })
const executorCtx = createExecutorContext(async () => ({
data: [{ name: "translator", mode: "subagent" }],
}))
//#when
const result = await resolveSubagentExecution(
args,
executorCtx,
"sisyphus",
"deep",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
)
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("translator")
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.3-codex",
})
})
test("uses system default model when inherited model is unavailable", async () => {
//#given
const args = createBaseArgs({ subagent_type: "translator" })
const executorCtx = createExecutorContext(async () => ({
data: [{ name: "translator", mode: "subagent" }],
}))
//#when
const result = await resolveSubagentExecution(
args,
executorCtx,
"sisyphus",
"deep",
undefined,
"anthropic/claude-opus-4-6",
)
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("translator")
expect(result.categoryModel).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
})
})
})
+1 -13
View File
@@ -15,9 +15,7 @@ export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string,
inheritedModel?: string,
systemDefaultModel?: string,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
const { client, agentOverrides } = executorCtx
@@ -126,16 +124,6 @@ Create the work plan directly - that's your job as the planning agent.`,
if (!categoryModel && matchedAgent.model) {
categoryModel = matchedAgent.model
}
if (!categoryModel) {
const fallbackModel = inheritedModel ?? systemDefaultModel
if (fallbackModel) {
const parsedFallback = parseModelString(fallbackModel)
if (parsedFallback) {
categoryModel = parsedFallback
}
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log("[delegate-task] Failed to resolve subagent execution", {
+1 -8
View File
@@ -226,14 +226,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
const resolution = await resolveSubagentExecution(
args,
options,
parentContext.agent,
categoryExamples,
inheritedModel,
systemDefaultModel,
)
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
if (resolution.error) {
return resolution.error
}
+7 -18
View File
@@ -1,15 +1,15 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { basename, join } from "path"
import { parseFrontmatter, sanitizeModelField, getOpenCodeConfigDir } from "../../shared"
import {
parseFrontmatter,
sanitizeModelField,
getOpenCodeConfigDir,
discoverPluginCommandDefinitions,
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import {
discoverInstalledPlugins,
loadPluginCommands,
loadPluginSkillsAsCommands,
} from "../../features/claude-code-plugin-loader"
import type { CommandInfo, CommandMetadata, CommandScope } from "./types"
export interface CommandDiscoveryOptions {
@@ -59,18 +59,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
}
function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[] {
if (options?.pluginsEnabled === false) {
return []
}
const { plugins } = discoverInstalledPlugins({
enabledPluginsOverride: options?.enabledPluginsOverride,
})
const pluginDefinitions = {
...loadPluginCommands(plugins),
...loadPluginSkillsAsCommands(plugins),
}
const pluginDefinitions = discoverPluginCommandDefinitions(options)
return Object.entries(pluginDefinitions).map(([name, definition]) => ({
name,
@@ -0,0 +1,28 @@
import { describe, expect, it } from "bun:test"
import { formatLoadedCommand } from "./command-output-formatter"
import type { CommandInfo } from "./types"
describe("command output formatter", () => {
describe("#given command template includes argument placeholders", () => {
it("#then replaces both placeholder forms", async () => {
// given
const command: CommandInfo = {
name: "daplug:templated",
metadata: {
name: "daplug:templated",
description: "Templated plugin command",
},
content: "Echo $ARGUMENTS and ${user_message}.",
scope: "plugin",
}
// when
const output = await formatLoadedCommand(command, "ship it")
// then
expect(output).toContain("Echo ship it and ship it.")
expect(output).not.toContain("$ARGUMENTS")
expect(output).not.toContain("${user_message}")
})
})
})
@@ -49,7 +49,9 @@ export async function formatLoadedCommand(
let finalContent = resolvedContent.trim()
if (userMessage) {
finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage)
finalContent = finalContent
.replace(/\$\{user_message\}/g, userMessage)
.replace(/\$ARGUMENTS/g, userMessage)
}
sections.push(finalContent)