diff --git a/src/agents/builtin-agents/hephaestus-agent.ts b/src/agents/builtin-agents/hephaestus-agent.ts index a32064c63..c05b1fa71 100644 --- a/src/agents/builtin-agents/hephaestus-agent.ts +++ b/src/agents/builtin-agents/hephaestus-agent.ts @@ -8,6 +8,7 @@ import { applyEnvironmentContext } from "./environment-context" import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides" import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" import { getGptApplyPatchPermission } from "../gpt-apply-patch-guard" +import { applyFrontierToolSchemaPermission } from "../frontier-tool-schema-guard" export function maybeCreateHephaestusConfig(input: { disabledAgents: string[] @@ -89,6 +90,13 @@ export function maybeCreateHephaestusConfig(input: { } const resolvedModel = hephaestusConfig.model ?? "" + hephaestusConfig.permission = applyFrontierToolSchemaPermission( + hephaestusConfig.permission, + resolvedModel, + hephaestusOverride?.permission, + (hephaestusOverride as { tools?: Record } | undefined)?.tools + ) + const gptDeny = getGptApplyPatchPermission(resolvedModel) if (Object.keys(gptDeny).length > 0 && hephaestusConfig.permission) { Object.assign(hephaestusConfig.permission, gptDeny) diff --git a/src/agents/builtin-agents/sisyphus-agent.test.ts b/src/agents/builtin-agents/sisyphus-agent.test.ts index e7289f6c0..b32ede5af 100644 --- a/src/agents/builtin-agents/sisyphus-agent.test.ts +++ b/src/agents/builtin-agents/sisyphus-agent.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, test } from "bun:test"; import { maybeCreateSisyphusConfig } from "./sisyphus-agent"; import type { AgentOverrides } from "../types"; @@ -12,7 +14,7 @@ describe("maybeCreateSisyphusConfig", () => { model: "openai/gpt-5.4", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; @@ -46,7 +48,7 @@ describe("maybeCreateSisyphusConfig", () => { model: "anthropic/claude-opus-4-7", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; @@ -73,6 +75,178 @@ describe("maybeCreateSisyphusConfig", () => { }); }); + describe("#given Opus 4.7 model with user override allowing grep and glob", () => { + test("#when config is created #then grep and glob are still denied", () => { + // given + const agentOverrides: AgentOverrides = { + sisyphus: { + model: "anthropic/claude-opus-4-7", + permission: { + grep: "allow", + glob: "allow", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateSisyphusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["anthropic/claude-opus-4-7"]), + systemDefaultModel: "anthropic/claude-opus-4-7", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given GPT 5.5 model with user override allowing grep and glob", () => { + test("#when config is created #then grep and glob are still denied", () => { + // given + const agentOverrides: AgentOverrides = { + sisyphus: { + model: "openai/gpt-5.5", + permission: { + grep: "allow", + glob: "allow", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateSisyphusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.5"]), + systemDefaultModel: "openai/gpt-5.5", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given frontier default model with category override to non-frontier model", () => { + test("#when config is created #then stale grep and glob denies are cleared", () => { + // given + const agentOverrides: AgentOverrides = { + sisyphus: { + category: "non-frontier", + }, + }; + const mergedCategories: Record = { + "non-frontier": { + model: "openai/gpt-5.4", + }, + }; + + // when + const config = maybeCreateSisyphusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"]), + systemDefaultModel: "anthropic/claude-opus-4-7", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.model).toBe("openai/gpt-5.4"); + expect(config?.permission).not.toHaveProperty("grep"); + expect(config?.permission).not.toHaveProperty("glob"); + }); + }); + + describe("#given non-frontier model with user override denying grep and glob", () => { + test("#when config is created #then explicit user denies are preserved", () => { + // given + const agentOverrides: AgentOverrides = { + sisyphus: { + model: "openai/gpt-5.4", + permission: { + grep: "deny", + glob: "deny", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateSisyphusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "openai/gpt-5.4", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given non-frontier model with legacy user tools denying grep and glob", () => { + test("#when config is created #then explicit legacy denies are preserved", () => { + // given + const legacyOverride = { + model: "openai/gpt-5.4", + tools: { + grep: false, + glob: false, + }, + }; + const agentOverrides: AgentOverrides = { + sisyphus: legacyOverride, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateSisyphusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "openai/gpt-5.4", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + describe("#given generic GPT model with user override allowing apply_patch", () => { test("#when config is created #then apply_patch is still denied", () => { // given @@ -81,7 +255,7 @@ describe("maybeCreateSisyphusConfig", () => { model: "openai/gpt-4o", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; diff --git a/src/agents/builtin-agents/sisyphus-agent.ts b/src/agents/builtin-agents/sisyphus-agent.ts index 97aef5f61..6cb91370f 100644 --- a/src/agents/builtin-agents/sisyphus-agent.ts +++ b/src/agents/builtin-agents/sisyphus-agent.ts @@ -8,6 +8,7 @@ import { applyOverrides } from "./agent-overrides" import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" import { createSisyphusAgent } from "../sisyphus" import { getGptApplyPatchPermission } from "../gpt-apply-patch-guard" +import { applyFrontierToolSchemaPermission } from "../frontier-tool-schema-guard" export function maybeCreateSisyphusConfig(input: { disabledAgents: string[] @@ -83,6 +84,13 @@ export function maybeCreateSisyphusConfig(input: { sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories, directory) const resolvedModel = sisyphusConfig.model ?? "" + sisyphusConfig.permission = applyFrontierToolSchemaPermission( + sisyphusConfig.permission, + resolvedModel, + sisyphusOverride?.permission, + (sisyphusOverride as { tools?: Record } | undefined)?.tools + ) + const gptDeny = getGptApplyPatchPermission(resolvedModel) if (Object.keys(gptDeny).length > 0 && sisyphusConfig.permission) { Object.assign(sisyphusConfig.permission, gptDeny) diff --git a/src/agents/frontier-tool-schema-guard.ts b/src/agents/frontier-tool-schema-guard.ts new file mode 100644 index 000000000..68158149a --- /dev/null +++ b/src/agents/frontier-tool-schema-guard.ts @@ -0,0 +1,41 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import { isGpt5_5Model } from "./types" +import type { PermissionValue } from "../shared/permission-compat" + +const FRONTIER_TOOL_SCHEMA_NAMES = ["grep", "glob"] as const +type MutablePermission = Record> + +function isOpus47Model(model: string): boolean { + const modelName = model.includes("/") ? (model.split("/").pop() ?? model) : model + return modelName.toLowerCase().includes("claude-opus-4-7") +} + +export function getFrontierToolSchemaPermission(model: string): Record { + return isOpus47Model(model) || isGpt5_5Model(model) + ? { grep: "deny" as const, glob: "deny" as const } + : {} +} + +export function applyFrontierToolSchemaPermission( + permission: AgentConfig["permission"] | undefined, + model: string, + explicitPermission?: AgentConfig["permission"], + explicitTools?: Record +): AgentConfig["permission"] | undefined { + if (!permission) return permission + + const nextPermission: MutablePermission = { ...permission } + const explicitPermissionMap = explicitPermission as MutablePermission | undefined + const frontierDeny = getFrontierToolSchemaPermission(model) + if (Object.keys(frontierDeny).length > 0) { + Object.assign(nextPermission, frontierDeny) + return nextPermission as AgentConfig["permission"] + } + + for (const toolName of FRONTIER_TOOL_SCHEMA_NAMES) { + if (explicitPermissionMap?.[toolName] === "deny") continue + if (explicitTools?.[toolName] === false) continue + delete nextPermission[toolName] + } + return nextPermission as AgentConfig["permission"] +} diff --git a/src/agents/hephaestus/agent.test.ts b/src/agents/hephaestus/agent.test.ts index f7d1087f6..ca1d65c01 100644 --- a/src/agents/hephaestus/agent.test.ts +++ b/src/agents/hephaestus/agent.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, test } from "bun:test"; import { getHephaestusPromptSource, @@ -321,7 +323,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => { model: "openai/gpt-5.4", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; @@ -355,7 +357,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => { model: "anthropic/claude-opus-4-7", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; @@ -389,7 +391,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => { model: "openai/gpt-4o", permission: { apply_patch: "allow", - }, + } as Record, }, }; const mergedCategories: Record = {}; @@ -414,4 +416,176 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => { expect(config?.permission).toHaveProperty("apply_patch", "deny"); }); }); + + describe("#given Opus 4.7 model with user override allowing grep and glob", () => { + test("#when config is created #then grep and glob are still denied", () => { + // given + const agentOverrides: AgentOverrides = { + hephaestus: { + model: "anthropic/claude-opus-4-7", + permission: { + grep: "allow", + glob: "allow", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateHephaestusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["anthropic/claude-opus-4-7"]), + systemDefaultModel: "anthropic/claude-opus-4-7", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given GPT 5.5 model with user override allowing grep and glob", () => { + test("#when config is created #then grep and glob are still denied", () => { + // given + const agentOverrides: AgentOverrides = { + hephaestus: { + model: "openai/gpt-5.5", + permission: { + grep: "allow", + glob: "allow", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateHephaestusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.5"]), + systemDefaultModel: "openai/gpt-5.5", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given frontier default model with category override to non-frontier model", () => { + test("#when config is created #then stale grep and glob denies are cleared", () => { + // given + const agentOverrides: AgentOverrides = { + hephaestus: { + category: "non-frontier", + }, + }; + const mergedCategories: Record = { + "non-frontier": { + model: "openai/gpt-5.4", + }, + }; + + // when + const config = maybeCreateHephaestusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.5", "openai/gpt-5.4"]), + systemDefaultModel: "openai/gpt-5.5", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.model).toBe("openai/gpt-5.4"); + expect(config?.permission).not.toHaveProperty("grep"); + expect(config?.permission).not.toHaveProperty("glob"); + }); + }); + + describe("#given non-frontier model with user override denying grep and glob", () => { + test("#when config is created #then explicit user denies are preserved", () => { + // given + const agentOverrides: AgentOverrides = { + hephaestus: { + model: "openai/gpt-5.4", + permission: { + grep: "deny", + glob: "deny", + } as Record, + }, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateHephaestusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "openai/gpt-5.4", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); + + describe("#given non-frontier model with legacy user tools denying grep and glob", () => { + test("#when config is created #then explicit legacy denies are preserved", () => { + // given + const legacyOverride = { + model: "openai/gpt-5.4", + tools: { + grep: false, + glob: false, + }, + }; + const agentOverrides: AgentOverrides = { + hephaestus: legacyOverride, + }; + const mergedCategories: Record = {}; + + // when + const config = maybeCreateHephaestusConfig({ + disabledAgents: [], + agentOverrides, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "openai/gpt-5.4", + isFirstRunNoCache: false, + availableAgents: [], + availableSkills: [], + availableCategories: [], + mergedCategories, + useTaskSystem: false, + }); + + // then + expect(config?.permission).toHaveProperty("grep", "deny"); + expect(config?.permission).toHaveProperty("glob", "deny"); + }); + }); }); diff --git a/src/agents/hephaestus/agent.ts b/src/agents/hephaestus/agent.ts index b348f30b7..3aa773bac 100644 --- a/src/agents/hephaestus/agent.ts +++ b/src/agents/hephaestus/agent.ts @@ -9,6 +9,7 @@ import type { } from "../dynamic-agent-prompt-builder"; import { categorizeTools, buildAgentIdentitySection } from "../dynamic-agent-prompt-builder"; import { getGptApplyPatchPermission } from "../gpt-apply-patch-guard"; +import { getFrontierToolSchemaPermission } from "../frontier-tool-schema-guard"; import { buildHephaestusPrompt as buildGptPrompt } from "./gpt"; import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex"; @@ -139,6 +140,7 @@ export function createHephaestusAgent( permission: { question: "allow", call_omo_agent: "deny", + ...getFrontierToolSchemaPermission(model), ...getGptApplyPatchPermission(model), } as AgentConfig["permission"], reasoningEffort: "medium", diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 21a36b8a0..d0bfb0979 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -13,6 +13,7 @@ import { buildGpt54SisyphusPrompt } from "./sisyphus/gpt-5-4"; import { buildGpt55SisyphusPrompt } from "./sisyphus/gpt-5-5"; import { buildTaskManagementSection } from "./sisyphus/default"; import { getGptApplyPatchPermission } from "./gpt-apply-patch-guard"; +import { getFrontierToolSchemaPermission } from "./frontier-tool-schema-guard"; const MODE: AgentMode = "primary"; export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = { @@ -501,6 +502,7 @@ export function createSisyphusAgent( permission: { question: "allow", call_omo_agent: "deny", + ...getFrontierToolSchemaPermission(model), ...getGptApplyPatchPermission(model), } as AgentConfig["permission"], reasoningEffort: "medium", @@ -527,6 +529,7 @@ export function createSisyphusAgent( permission: { question: "allow", call_omo_agent: "deny", + ...getFrontierToolSchemaPermission(model), ...getGptApplyPatchPermission(model), } as AgentConfig["permission"], reasoningEffort: "medium", @@ -567,6 +570,7 @@ export function createSisyphusAgent( const permission = { question: "allow", call_omo_agent: "deny", + ...getFrontierToolSchemaPermission(model), ...getGptApplyPatchPermission(model), } as AgentConfig["permission"]; const base = { diff --git a/src/agents/tool-restrictions.test.ts b/src/agents/tool-restrictions.test.ts index 3ae7bfcfe..1d0fed4fd 100644 --- a/src/agents/tool-restrictions.test.ts +++ b/src/agents/tool-restrictions.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, test, expect } from "bun:test" import { createOracleAgent } from "./oracle" import { createLibrarianAgent } from "./librarian" @@ -6,6 +8,7 @@ import { createMomusAgent } from "./momus" import { createMetisAgent } from "./metis" import { createAtlasAgent } from "./atlas" import { createSisyphusAgent } from "./sisyphus" +import { createHephaestusAgent } from "./hephaestus" const TEST_MODEL = "anthropic/claude-sonnet-4-5" @@ -131,4 +134,47 @@ describe("read-only agent tool restrictions", () => { expect(claudePermission["apply_patch"]).toBeUndefined() }) }) + + describe("Sisyphus and Hephaestus frontier tool schema restrictions", () => { + test("deny grep and glob for Opus 4.7 and GPT 5.5 models", () => { + // given + const frontierAgents = [ + createSisyphusAgent("anthropic/claude-opus-4-7"), + createSisyphusAgent("openai/gpt-5.5"), + createHephaestusAgent("anthropic/claude-opus-4-7"), + createHephaestusAgent("openai/gpt-5.5"), + ] + + // when + const permissions = frontierAgents.map( + (agent) => (agent.permission ?? {}) as Record, + ) + + // then + for (const permission of permissions) { + expect(permission.grep).toBe("deny") + expect(permission.glob).toBe("deny") + } + }) + + test("keeps grep and glob available for other models", () => { + // given + const otherAgents = [ + createSisyphusAgent("anthropic/claude-sonnet-4-5"), + createSisyphusAgent("openai/gpt-5.4"), + createHephaestusAgent("openai/gpt-5.4"), + ] + + // when + const permissions = otherAgents.map( + (agent) => (agent.permission ?? {}) as Record, + ) + + // then + for (const permission of permissions) { + expect(permission.grep).toBeUndefined() + expect(permission.glob).toBeUndefined() + } + }) + }) }) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index e63f4bf4d..943f246fb 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -3,7 +3,7 @@ import { describe, test, expect, mock, beforeEach, spyOn, afterAll } from 'bun:t import type { TmuxConfig } from '../../config/schema' import type { WindowState, PaneAction } from './types' import type { ActionResult, ExecuteContext } from './action-executor' -import type { TmuxUtilDeps } from './manager' +import type { TmuxSessionManager as TmuxSessionManagerType, TmuxUtilDeps } from './manager' import * as sharedModule from '../../shared' type ExecuteActionsResult = { @@ -287,20 +287,95 @@ describe('TmuxSessionManager', () => { test('falls back to default port when serverUrl has port 0', async () => { // given - mockIsInsideTmux.mockReturnValue(true) - const { TmuxSessionManager } = await import('./manager') - const ctx = { - ...createMockContext(), - serverUrl: new URL('http://127.0.0.1:0/'), - } - const config = createTmuxConfig({ enabled: true, - layout: 'main-vertical', - main_pane_size: 60, - main_pane_min_width: 80, - agent_pane_min_width: 40, }) + const previousOpenCodePort = process.env.OPENCODE_PORT + delete process.env.OPENCODE_PORT + let manager: TmuxSessionManagerType | undefined + try { + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = { + ...createMockContext(), + serverUrl: new URL('http://127.0.0.1:0/'), + } + const config = createTmuxConfig({ enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, }) - // when - const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + // when + manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + } finally { + if (previousOpenCodePort === undefined) { + delete process.env.OPENCODE_PORT + } else { + process.env.OPENCODE_PORT = previousOpenCodePort + } + } + + // then + expect((manager as any).serverUrl).toBe('http://localhost:4096') + }) + + test('falls back to configured OPENCODE_PORT when serverUrl has port 0', async () => { + // given + const previousOpenCodePort = process.env.OPENCODE_PORT + process.env.OPENCODE_PORT = '5678' + let manager: TmuxSessionManagerType | undefined + try { + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = { + ...createMockContext(), + serverUrl: new URL('http://127.0.0.1:0/'), + } + const config = createTmuxConfig({ enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, }) + + // when + manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + } finally { + if (previousOpenCodePort === undefined) { + delete process.env.OPENCODE_PORT + } else { + process.env.OPENCODE_PORT = previousOpenCodePort + } + } + + // then + expect((manager as any).serverUrl).toBe('http://localhost:5678') + }) + + test('ignores invalid OPENCODE_PORT when serverUrl has port 0', async () => { + // given + const previousOpenCodePort = process.env.OPENCODE_PORT + process.env.OPENCODE_PORT = 'not-a-port' + let manager: TmuxSessionManagerType | undefined + try { + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = { + ...createMockContext(), + serverUrl: new URL('http://127.0.0.1:0/'), + } + const config = createTmuxConfig({ enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, }) + + // when + manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + } finally { + if (previousOpenCodePort === undefined) { + delete process.env.OPENCODE_PORT + } else { + process.env.OPENCODE_PORT = previousOpenCodePort + } + } // then expect((manager as any).serverUrl).toBe('http://localhost:4096') @@ -1989,7 +2064,7 @@ describe('TmuxSessionManager', () => { const cleanupPromise = manager.cleanup() // then - await expect(cleanupPromise).resolves.toBeUndefined() + expect(await cleanupPromise).toBeUndefined() expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 3340fb55d..353bdffec 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -72,7 +72,11 @@ export class TmuxSessionManager { this.client = ctx.client this.tmuxConfig = tmuxConfig this.deps = deps - const defaultPort = process.env.OPENCODE_PORT ?? "4096" + const configuredPort = process.env.OPENCODE_PORT + const parsedPort = configuredPort ? Number(configuredPort) : 4096 + const defaultPort = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + ? String(parsedPort) + : "4096" const fallbackUrl = `http://localhost:${defaultPort}` const rawServerUrl = ctx.serverUrl?.toString() try { diff --git a/src/hooks/auto-slash-command/executor-resolution.test.ts b/src/hooks/auto-slash-command/executor-resolution.test.ts index 45c905467..82d924902 100644 --- a/src/hooks/auto-slash-command/executor-resolution.test.ts +++ b/src/hooks/auto-slash-command/executor-resolution.test.ts @@ -1,8 +1,9 @@ +/// + import { afterEach, describe, expect, it, spyOn } from "bun:test" import type { LoadedSkill } from "../../features/opencode-skill-loader" import * as shared from "../../shared" -import * as slashcommand from "../../tools/slashcommand" -import { executeSlashCommand } from "./executor" +import * as slashcommand from "../../tools/slashcommand/command-discovery" let resolveCommandsInTextSpy: { mockRestore: () => void } | undefined let resolveFileReferencesInTextSpy: { mockRestore: () => void } | undefined @@ -38,6 +39,11 @@ function restoreExecutorSpies(): void { discoverCommandsSyncSpy = undefined } +async function executeSlashCommand(...args: Parameters): ReturnType { + const module = await import(`./executor?test=${Date.now()}-${Math.random()}`) + return module.executeSlashCommand(...args) +} + afterEach(restoreExecutorSpies) function createRestrictedSkill(): LoadedSkill { diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index eedd8881f..0b5c7ceb9 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -1,10 +1,8 @@ import { dirname } from "path" -import { - resolveCommandsInText, - resolveFileReferencesInText, -} from "../../shared" +import { resolveCommandsInText } from "../../shared/command-executor/resolve-commands-in-text" +import { resolveFileReferencesInText } from "../../shared/file-reference-resolver" import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader" -import { discoverCommandsSync } from "../../tools/slashcommand" +import * as commandDiscovery from "../../tools/slashcommand/command-discovery" import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types" import type { ParsedSlashCommand } from "./types" @@ -47,7 +45,7 @@ export interface ExecutorOptions { async function discoverAllCommands(options?: ExecutorOptions): Promise { - const discoveredCommands = discoverCommandsSync(options?.directory ?? process.cwd(), { + const discoveredCommands = commandDiscovery.discoverCommandsSync(options?.directory ?? process.cwd(), { pluginsEnabled: options?.pluginsEnabled, enabledPluginsOverride: options?.enabledPluginsOverride, }) diff --git a/src/tools/skill/tools.factory.test.ts b/src/tools/skill/tools.factory.test.ts index 5b9a5ba30..b1e992607 100644 --- a/src/tools/skill/tools.factory.test.ts +++ b/src/tools/skill/tools.factory.test.ts @@ -4,13 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:te import type { ToolContext } from "@opencode-ai/plugin/tool" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import * as skillContent from "../../features/opencode-skill-loader/skill-content" +import * as commandDiscovery from "../slashcommand/command-discovery" const discoverCommandsSync = mock(() => []) -mock.module("../slashcommand/command-discovery", () => ({ - discoverCommandsSync, -})) - function createMockSkill(name: string): LoadedSkill { return { name, @@ -49,7 +46,13 @@ function createMockContext(sessionID: string): ToolContext { } } +async function createSkillTool(...args: Parameters): ReturnType { + const module = await import(`./tools?test=${Date.now()}-${Math.random()}`) + return module.createSkillTool(...args) +} + beforeEach(() => { + spyOn(commandDiscovery, "discoverCommandsSync").mockImplementation(discoverCommandsSync) spyOn(skillContent, "getAllSkills").mockImplementation(getAllSkills) spyOn(skillContent, "clearSkillCache").mockImplementation(clearSkillCache) }) @@ -65,8 +68,7 @@ describe("createSkillTool", () => { const baselineDiscoverCommandsSyncCalls = discoverCommandsSync.mock.calls.length // when - const { createSkillTool } = await import("./tools") - const skillTool = createSkillTool({}) + const skillTool = await createSkillTool({}) // then expect(discoverCommandsSync.mock.calls.length).toBe(baselineDiscoverCommandsSyncCalls) @@ -82,8 +84,7 @@ describe("createSkillTool", () => { const baselineGetAllSkillsCalls = getAllSkills.mock.calls.length // when - const { createSkillTool } = await import("./tools") - const skillTool = createSkillTool({}) + const skillTool = await createSkillTool({}) // then expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls) @@ -99,8 +100,7 @@ describe("createSkillTool", () => { const sessionContext = createMockContext("session-clear-once") // when - const { createSkillTool } = await import("./tools") - const skillTool = createSkillTool({}) + const skillTool = await createSkillTool({}) void skillTool.description await flushMicrotasks() await skillTool.execute({ name: "lazy-skill" }, sessionContext) @@ -116,8 +116,7 @@ describe("createSkillTool", () => { const baselineGetAllSkillsCalls = getAllSkills.mock.calls.length const sessionAContext = createMockContext("session-a") const sessionBContext = createMockContext("session-b") - const { createSkillTool } = await import("./tools") - const skillTool = createSkillTool({}) + const skillTool = await createSkillTool({}) // when await skillTool.execute({ name: "lazy-skill" }, sessionAContext) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index d49936f95..81bb14485 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -7,7 +7,7 @@ import type { SkillArgs, SkillLoadOptions } from "./types" import type { LoadedSkill } from "../../features/opencode-skill-loader" import { clearSkillCache, getAllSkills } from "../../features/opencode-skill-loader/skill-content" import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content" -import { discoverCommandsSync } from "../slashcommand/command-discovery" +import * as commandDiscovery from "../slashcommand/command-discovery" import type { CommandInfo } from "../slashcommand/types" import { formatLoadedCommand } from "../slashcommand/command-output-formatter" import { formatCombinedDescription } from "./description-formatter" @@ -37,14 +37,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider, })) ?? [] - const allSkills = !options.skills - ? discovered - : [ - ...discovered, - ...options.skills.filter( - (skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name) - ), - ] + const allSkills = options.skills ? [...options.skills] : discovered if (options.nativeSkills) { try { @@ -58,7 +51,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition } const getCommands = (): CommandInfo[] => { - return discoverCommandsSync(undefined, { + return commandDiscovery.discoverCommandsSync(undefined, { pluginsEnabled: options.pluginsEnabled, enabledPluginsOverride: options.enabledPluginsOverride, }) ?? [] diff --git a/src/tools/slashcommand/command-discovery-deps.ts b/src/tools/slashcommand/command-discovery-deps.ts new file mode 100644 index 000000000..5465e0dfc --- /dev/null +++ b/src/tools/slashcommand/command-discovery-deps.ts @@ -0,0 +1,6 @@ +export { EXCLUDED_DIRS } from "../../shared/excluded-dirs" +export { parseFrontmatter } from "../../shared/frontmatter" +export { sanitizeModelField } from "../../shared/model-sanitizer" +export { getOpenCodeCommandDirs } from "../../shared/opencode-command-dirs" +export { discoverPluginCommandDefinitions } from "../../shared/plugin-command-discovery" +export { findProjectOpencodeCommandDirs } from "../../shared/project-discovery-dirs" diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 855f6dc28..0900dec42 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -7,11 +7,12 @@ import { getOpenCodeCommandDirs, discoverPluginCommandDefinitions, EXCLUDED_DIRS, -} from "../../shared" +} from "./command-discovery-deps" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" -import { getClaudeConfigDir, log } from "../../shared" -import { loadBuiltinCommands } from "../../features/builtin-commands" +import { getClaudeConfigDir } from "../../shared/claude-config-dir" +import { log } from "../../shared/logger" +import { loadBuiltinCommands } from "../../features/builtin-commands/commands" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" export interface CommandDiscoveryOptions {