diff --git a/kompass.jsonc b/kompass.jsonc index ed33dbe..2a071d0 100644 --- a/kompass.jsonc +++ b/kompass.jsonc @@ -61,6 +61,7 @@ "adapters": { "opencode": { "agentMode": "all", + "subtaskCommandMode": "kompass", }, }, } diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index b023081..fd48ca4 100644 --- a/packages/core/lib/config.ts +++ b/packages/core/lib/config.ts @@ -160,6 +160,7 @@ export interface KompassConfig { adapters?: { opencode?: { agentMode?: "subagent" | "primary" | "all"; + subtaskCommandMode?: "kompass" | "all" | "off"; }; }; } @@ -207,6 +208,7 @@ export interface MergedKompassConfig { adapters: { opencode: { agentMode: "subagent" | "primary" | "all"; + subtaskCommandMode: "kompass" | "all" | "off"; }; }; } @@ -697,6 +699,9 @@ export function mergeWithDefaults( config?.adapters?.opencode?.agentMode ?? config?.defaults?.agentMode ?? "all", + subtaskCommandMode: + config?.adapters?.opencode?.subtaskCommandMode ?? + "kompass", }, }, }; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 7d66dbe..0cdb3fa 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -266,6 +266,19 @@ describe("object-based config", () => { assert.equal(config.agents.enabled.includes("reviewer"), false); assert.equal(config.components.enabled.includes("dev-flow"), false); assert.equal(config.components.paths.commit, "components/custom-commit.md"); + assert.equal(config.adapters.opencode.subtaskCommandMode, "kompass"); + }); + + test("supports adapter-specific subtask command stripping mode", () => { + const config = mergeWithDefaults({ + adapters: { + opencode: { + subtaskCommandMode: "all", + }, + }, + }); + + assert.equal(config.adapters.opencode.subtaskCommandMode, "all"); }); test("supports skill entry maps", () => { diff --git a/packages/opencode/.opencode/kompass.jsonc b/packages/opencode/.opencode/kompass.jsonc index aa444d0..2b50597 100644 --- a/packages/opencode/.opencode/kompass.jsonc +++ b/packages/opencode/.opencode/kompass.jsonc @@ -101,7 +101,8 @@ }, "adapters": { "opencode": { - "agentMode": "all" + "agentMode": "all", + "subtaskCommandMode": "kompass" } } } diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index d0b69ca..99b0382 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -14,6 +14,7 @@ import { type MergedKompassConfig, type Shell, } from "../core/index.ts"; +import { DEFAULT_COMMAND_NAMES } from "../core/lib/config.ts"; import { applyAgentsConfig, applyCommandsConfig, applySkillsConfig } from "./config.ts"; import { createPluginLogger, getErrorDetails, type PluginLogger } from "./logging.ts"; import { @@ -203,11 +204,38 @@ export function getCommandExecution( }; } +export function removeSubtaskCommands(output: CommandExecuteBeforeOutput): number { + let removed = 0; + + for (const part of output.parts) { + if (part.type !== "subtask") continue; + if (!("command" in part) || typeof part.command !== "string") continue; + + delete (part as { command?: string }).command; + removed++; + } + + return removed; +} + +export function shouldRemoveSubtaskCommand( + command: string, + config: MergedKompassConfig, + kompassCommands = new Set(DEFAULT_COMMAND_NAMES), +): boolean { + const mode = config.adapters.opencode.subtaskCommandMode; + + if (mode === "off") return false; + if (mode === "all") return true; + + return kompassCommands.has(command); +} + export function removeSyntheticAgentHandoff(output: ChatMessageOutput): boolean { const filteredParts = output.parts.filter((part) => !( part.type === "text" && part.synthetic === true && - part.text.includes(AGENT_HANDOFF_MARKER) + part.text.toLowerCase().includes(AGENT_HANDOFF_MARKER) )); if (filteredParts.length === output.parts.length) return false; @@ -409,6 +437,16 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { } const tools = await createToolsSafely(); + let config = mergeWithDefaults(null); + try { + config = mergeWithDefaults(await loadKompassConfig(worktree)); + } catch (error) { + await logger.warn("Falling back to default Kompass runtime config", { + worktree, + ...getErrorDetails(error), + }); + } + const kompassCommands = new Set(DEFAULT_COMMAND_NAMES); return { tool: tools, @@ -438,8 +476,20 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { }, async "command.execute.before"(input, output) { try { + const removedSubtaskCommands = shouldRemoveSubtaskCommand(input.command, config, kompassCommands) + ? removeSubtaskCommands(output) + : 0; const commandExecution = getCommandExecution(input, output); + if (removedSubtaskCommands > 0) { + await logger.info("Removed subtask command payload", { + command: input.command, + arguments: input.arguments, + sessionID: input.sessionID, + removedSubtaskCommands, + }); + } + if (!commandExecution) return; await logger.info("Executing Kompass command", commandExecution as Record); diff --git a/packages/opencode/test/task-hook.test.ts b/packages/opencode/test/task-hook.test.ts index 1bae885..fab9015 100644 --- a/packages/opencode/test/task-hook.test.ts +++ b/packages/opencode/test/task-hook.test.ts @@ -5,7 +5,11 @@ import { expandSlashCommandPrompt, getCommandExecution, getTaskToolExecution, + removeSyntheticAgentHandoff, + removeSubtaskCommands, + shouldRemoveSubtaskCommand, } from "../index.ts"; +import { mergeWithDefaults } from "../../core/lib/config.ts"; describe("getTaskToolExecution", () => { test("expands slash commands for task tool calls", async () => { @@ -165,3 +169,116 @@ describe("getCommandExecution", () => { assert.equal(execution, undefined); }); }); + +describe("removeSubtaskCommands", () => { + test("removes command from subtask parts", () => { + const output = { + parts: [ + { + id: "part-1", + sessionID: "session-3", + messageID: "message-1", + type: "subtask", + prompt: "expanded command prompt", + description: "Run review command", + agent: "general", + command: "review", + }, + { + id: "part-2", + sessionID: "session-3", + messageID: "message-1", + type: "text", + text: "keep this", + }, + ], + }; + + const removed = removeSubtaskCommands(output as never); + + assert.equal(removed, 1); + assert.equal("command" in output.parts[0], false); + }); +}); + +describe("shouldRemoveSubtaskCommand", () => { + test("defaults to stripping commands only for Kompass commands", () => { + const config = mergeWithDefaults(null); + + assert.equal(shouldRemoveSubtaskCommand("review", config), true); + assert.equal(shouldRemoveSubtaskCommand("third-party", config), false); + }); + + test("supports enabling stripping for all commands", () => { + const config = mergeWithDefaults({ + adapters: { + opencode: { + subtaskCommandMode: "all", + }, + }, + }); + + assert.equal(shouldRemoveSubtaskCommand("third-party", config), true); + }); + + test("supports disabling stripping entirely", () => { + const config = mergeWithDefaults({ + adapters: { + opencode: { + subtaskCommandMode: "off", + }, + }, + }); + + assert.equal(shouldRemoveSubtaskCommand("review", config), false); + }); +}); + +describe("removeSyntheticAgentHandoff", () => { + test("removes the legacy synthetic agent handoff text", () => { + const output = { + parts: [ + { + id: "part-1", + sessionID: "session-3", + messageID: "message-1", + type: "text", + text: "Please generate a prompt and call the task tool with subagent: planner", + synthetic: true, + }, + { + id: "part-2", + sessionID: "session-3", + messageID: "message-1", + type: "text", + text: "keep this", + }, + ], + }; + + const removed = removeSyntheticAgentHandoff(output as never); + + assert.equal(removed, true); + assert.equal(output.parts.length, 1); + assert.equal(output.parts[0]?.text, "keep this"); + }); + + test("keeps non-synthetic text untouched", () => { + const output = { + parts: [ + { + id: "part-1", + sessionID: "session-4", + messageID: "message-1", + type: "text", + text: "Summarize the task tool output above and continue with your task.", + }, + ], + }; + + const removed = removeSyntheticAgentHandoff(output as never); + + assert.equal(removed, false); + assert.equal(output.parts.length, 1); + }); +}); diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 59080ed..0b23e38 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -253,4 +253,126 @@ describe("createOpenCodeTools", () => { } }); }); + + test("strips subtask command payload only for Kompass commands by default", async () => { + await withTempHome(async () => { + const plugin = await OpenCodeCompassPlugin({ + $: (() => { + throw new Error("not implemented"); + }) as never, + client: createMockClient() as never, + directory: process.cwd(), + worktree: process.cwd(), + } as never); + + const kompassOutput = { + parts: [ + { + id: "part-1", + sessionID: "session-1", + messageID: "message-1", + type: "subtask", + prompt: "expanded", + description: "Run review command", + agent: "reviewer", + command: "review", + }, + ], + }; + + await plugin["command.execute.before"]?.( + { + command: "review", + sessionID: "session-1", + arguments: "", + } as never, + kompassOutput as never, + ); + + assert.equal("command" in kompassOutput.parts[0], false); + + const thirdPartyOutput = { + parts: [ + { + id: "part-2", + sessionID: "session-1", + messageID: "message-2", + type: "subtask", + prompt: "expanded", + description: "Run external command", + agent: "reviewer", + command: "third-party", + }, + ], + }; + + await plugin["command.execute.before"]?.( + { + command: "third-party", + sessionID: "session-1", + arguments: "", + } as never, + thirdPartyOutput as never, + ); + + assert.equal((thirdPartyOutput.parts[0] as { command?: string }).command, "third-party"); + }); + }); + + test("supports stripping subtask command payload for all commands via config", async () => { + await withTempHome(async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-tools-subtask-mode-")); + + try { + await mkdir(path.join(tempDir, ".opencode"), { recursive: true }); + await writeFile( + path.join(tempDir, ".opencode", "kompass.jsonc"), + `{ + "adapters": { + "opencode": { + "subtaskCommandMode": "all" + } + } + }`, + ); + + const plugin = await OpenCodeCompassPlugin({ + $: (() => { + throw new Error("not implemented"); + }) as never, + client: createMockClient() as never, + directory: tempDir, + worktree: tempDir, + } as never); + + const output = { + parts: [ + { + id: "part-1", + sessionID: "session-1", + messageID: "message-1", + type: "subtask", + prompt: "expanded", + description: "Run external command", + agent: "reviewer", + command: "third-party", + }, + ], + }; + + await plugin["command.execute.before"]?.( + { + command: "third-party", + sessionID: "session-1", + arguments: "", + } as never, + output as never, + ); + + assert.equal("command" in output.parts[0], false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); });