From ec121a730faec6e803faabcb893b11a5aea3f22b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:44:53 +0900 Subject: [PATCH] feat(config): add team-mode configuration schema and merge support --- src/config/index.ts | 1 + src/config/schema.ts | 1 + src/config/schema/agent-names.ts | 1 + src/config/schema/hooks.ts | 1 + .../schema/oh-my-opencode-config.test.ts | 40 ++++++ src/config/schema/oh-my-opencode-config.ts | 2 + src/config/schema/team-mode.test.ts | 48 +++++++ src/config/schema/team-mode.ts | 18 +++ src/plugin-config.test.ts | 136 +++++++++++++++++- src/plugin-config.ts | 1 + 10 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/config/schema/oh-my-opencode-config.test.ts create mode 100644 src/config/schema/team-mode.test.ts create mode 100644 src/config/schema/team-mode.ts diff --git a/src/config/index.ts b/src/config/index.ts index 57a347d3a..122dc1aae 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -21,4 +21,5 @@ export type { RuntimeFallbackConfig, ModelCapabilitiesConfig, FallbackModels, + TeamModeConfig, } from "./schema" diff --git a/src/config/schema.ts b/src/config/schema.ts index 04dd0b15b..f0394d6da 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -18,6 +18,7 @@ export * from "./schema/notification" export * from "./schema/oh-my-opencode-config" export * from "./schema/ralph-loop" export * from "./schema/runtime-fallback" +export * from "./schema/team-mode" export * from "./schema/skills" export * from "./schema/sisyphus" export * from "./schema/sisyphus-agent" diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts index e820e5746..7fefdadce 100644 --- a/src/config/schema/agent-names.ts +++ b/src/config/schema/agent-names.ts @@ -22,6 +22,7 @@ export const BuiltinSkillNameSchema = z.enum([ "git-master", "review-work", "ai-slop-remover", + "team-mode", ]) export const OverridableAgentNameSchema = z.enum([ diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index fea9c6371..80e4c71dd 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -38,6 +38,7 @@ export const HookNameSchema = z.enum([ "delegate-task-retry", "prometheus-md-only", "sisyphus-junior-notepad", + "team-tool-gating", "no-sisyphus-gpt", "no-hephaestus-non-gpt", "start-work", diff --git a/src/config/schema/oh-my-opencode-config.test.ts b/src/config/schema/oh-my-opencode-config.test.ts new file mode 100644 index 000000000..6fef426ac --- /dev/null +++ b/src/config/schema/oh-my-opencode-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test" +import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config" + +describe("OhMyOpenCodeConfigSchema team_mode", () => { + it("accepts team_mode when provided", () => { + // given + const rawConfig = { + team_mode: { + enabled: true, + max_parallel_members: 2, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.team_mode).toMatchObject({ + enabled: true, + max_parallel_members: 2, + }) + } + }) + + it("allows team_mode omission", () => { + // given + const rawConfig = {} + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.team_mode).toBeUndefined() + } + }) +}) diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index e62413d26..67cace71a 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -17,6 +17,7 @@ import { OpenClawConfigSchema } from "./openclaw" import { ModelCapabilitiesConfigSchema } from "./model-capabilities" import { RalphLoopConfigSchema } from "./ralph-loop" import { RuntimeFallbackConfigSchema } from "./runtime-fallback" +import { TeamModeConfigSchema } from "./team-mode" import { SkillsConfigSchema } from "./skills" import { SisyphusConfigSchema } from "./sisyphus" import { SisyphusAgentConfigSchema } from "./sisyphus-agent" @@ -63,6 +64,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ notification: NotificationConfigSchema.optional(), model_capabilities: ModelCapabilitiesConfigSchema.optional(), openclaw: OpenClawConfigSchema.optional(), + team_mode: TeamModeConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(), git_master: GitMasterConfigSchema.default({ commit_footer: true, diff --git a/src/config/schema/team-mode.test.ts b/src/config/schema/team-mode.test.ts new file mode 100644 index 000000000..4c95eb361 --- /dev/null +++ b/src/config/schema/team-mode.test.ts @@ -0,0 +1,48 @@ +/// + +import { describe, expect, test } from "bun:test" + +import { TeamModeConfigSchema } from "./team-mode" + +describe("TeamModeConfigSchema", () => { + describe("#given all fields are omitted", () => { + test("#when parsed #then it returns the default team mode config", () => { + // given + const input = {} + + // when + const result = TeamModeConfigSchema.parse(input) + + // then + expect(result).toEqual({ + enabled: false, + tmux_visualization: false, + max_parallel_members: 4, + max_members: 8, + max_messages_per_run: 10000, + max_wall_clock_minutes: 120, + max_member_turns: 500, + message_payload_max_bytes: 32768, + recipient_unread_max_bytes: 262144, + mailbox_poll_interval_ms: 3000, + }) + }) + }) + + describe("#given invalid bounds are provided", () => { + test("#when parsed #then it rejects out of range values", () => { + // given + const invalidInputs = [ + { max_parallel_members: -1 }, + { max_members: 9 }, + { message_payload_max_bytes: 512 }, + ] + + // when + const results = invalidInputs.map((input) => TeamModeConfigSchema.safeParse(input)) + + // then + expect(results.every((result) => !result.success)).toBe(true) + }) + }) +}) diff --git a/src/config/schema/team-mode.ts b/src/config/schema/team-mode.ts new file mode 100644 index 000000000..49add56fd --- /dev/null +++ b/src/config/schema/team-mode.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + +/** Team Mode config - see .sisyphus/plans/team-mode.md (D-01/D-25). */ +export const TeamModeConfigSchema = z.object({ + enabled: z.boolean().default(false), + tmux_visualization: z.boolean().default(false), + max_parallel_members: z.number().int().min(1).max(8).default(4), + max_members: z.number().int().min(1).max(8).default(8), + max_messages_per_run: z.number().int().min(1).default(10000), + max_wall_clock_minutes: z.number().int().min(1).default(120), + max_member_turns: z.number().int().min(1).default(500), + base_dir: z.string().optional(), + message_payload_max_bytes: z.number().int().min(1024).default(32768), + recipient_unread_max_bytes: z.number().int().min(1024).default(262144), + mailbox_poll_interval_ms: z.number().int().min(500).default(3000), +}) + +export type TeamModeConfig = z.infer diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 62de92458..c8bac63ba 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,14 +1,16 @@ -import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { afterEach, describe, expect, it, mock } from "bun:test"; import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import * as shared from "./shared" import { mergeConfigs, parseConfigPartially } from "./plugin-config"; -import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type TeamModeConfig } from "./config"; const tempDirs: string[] = [] +type ConfigInput = Omit, "team_mode"> & { + team_mode?: Partial +} -function createConfig(config: Partial): OhMyOpenCodeConfig { +function createConfig(config: ConfigInput): OhMyOpenCodeConfig { return OhMyOpenCodeConfigSchema.parse(config) } @@ -18,12 +20,35 @@ async function importFreshPluginConfigModule(): Promise { mock.restore() + delete process.env.OPENCODE_CONFIG_DIR for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } }) +function createLoadPluginConfigTestContext(prefix: string): { + rootDir: string + userConfigDir: string + projectDir: string + projectConfigDir: string +} { + const rootDir = mkdtempSync(join(tmpdir(), prefix)) + const userConfigDir = join(rootDir, "user-config") + const projectDir = join(rootDir, "project") + const projectConfigDir = join(projectDir, ".opencode") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(projectConfigDir, { recursive: true }) + + return { rootDir, userConfigDir, projectDir, projectConfigDir } +} + +function writeJsonFile(filePath: string, value: Record): void { + writeFileSync(filePath, JSON.stringify(value)) +} + describe("mergeConfigs", () => { describe("categories merging", () => { // given base config has categories, override has different categories @@ -121,6 +146,29 @@ describe("mergeConfigs", () => { expect(result.agents?.explore).toMatchObject({ model: "anthropic/claude-haiku-4-5" }); }); + it("should deep merge team_mode", () => { + const base = createConfig({ + team_mode: { + enabled: false, + tmux_visualization: false, + max_parallel_members: 2, + }, + }); + + const override = { + team_mode: { + enabled: true, + }, + } as OhMyOpenCodeConfig; + + const result = mergeConfigs(base, override); + + expect(result.team_mode).toMatchObject({ + enabled: true, + max_parallel_members: 2, + }); + }); + it("should merge disabled arrays without duplicates", () => { const base = createConfig({ disabled_hooks: ["comment-checker", "think-mode"], @@ -157,6 +205,7 @@ describe("mergeConfigs", () => { }); }); + describe("parseConfigPartially", () => { describe("disabled_hooks compatibility", () => { //#given a config with a future hook name unknown to this version @@ -511,4 +560,83 @@ describe("loadPluginConfig", () => { git_env_prefix: "GIT_MASTER=1", }) }) + + describe("team_mode.tmux_visualization", () => { + it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => { + // given + const { userConfigDir, projectDir } = createLoadPluginConfigTestContext("omo-plugin-config-team-mode-user-") + + writeJsonFile(join(userConfigDir, "oh-my-openagent.json"), { + team_mode: { + enabled: true, + }, + }) + writeJsonFile(join(userConfigDir, "oh-my-opencode.json"), { + agents: { + oracle: { + model: "openai/gpt-5.4", + }, + }, + }) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + expect(config.team_mode?.enabled).toBe(true) + expect(config.team_mode?.tmux_visualization).toBe(false) + }) + + it("#given canonical user config lacks team_mode and legacy config only enables team_mode #when loadPluginConfig runs #then canonical config wins and tmux_visualization stays effectively false", async () => { + // given + const { userConfigDir, projectDir } = createLoadPluginConfigTestContext("omo-plugin-config-team-mode-legacy-") + + writeJsonFile(join(userConfigDir, "oh-my-openagent.json"), { + hashline_edit: true, + }) + writeJsonFile(join(userConfigDir, "oh-my-opencode.json"), { + team_mode: { + enabled: true, + }, + }) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + expect(config.team_mode).toBeUndefined() + expect(config.team_mode?.tmux_visualization ?? false).toBe(false) + }) + + it("#given canonical user config lacks team_mode and legacy config sets tmux_visualization=true #when loadPluginConfig runs #then legacy team_mode is not promoted into the loaded config", async () => { + // given + const { userConfigDir, projectDir } = createLoadPluginConfigTestContext("omo-plugin-config-team-mode-visualization-") + + writeJsonFile(join(userConfigDir, "oh-my-openagent.json"), { + hashline_edit: true, + }) + writeJsonFile(join(userConfigDir, "oh-my-opencode.json"), { + team_mode: { + enabled: true, + tmux_visualization: true, + }, + }) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + // This proves a concurrent canonical file suppresses the legacy team_mode subtree entirely. + expect(config.team_mode).toBeUndefined() + }) + }) }) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index a5853af72..008c7cd09 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -141,6 +141,7 @@ export function mergeConfigs( ...override, agents: deepMerge(base.agents, override.agents), categories: deepMerge(base.categories, override.categories), + team_mode: deepMerge(base.team_mode, override.team_mode), agent_definitions: [ ...new Set([ ...(base.agent_definitions ?? []),