From 873c9e5b82f62ff21dd883670a353c8168405ed1 Mon Sep 17 00:00:00 2001 From: armin1024 Date: Thu, 19 Mar 2026 14:35:32 +0800 Subject: [PATCH] fix(task): preserve subagent todo permissions --- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/task.ts | 23 ++--- packages/opencode/test/tool/registry.test.ts | 13 +++ packages/opencode/test/tool/task.test.ts | 88 ++++++++++++++++++++ 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/tool/task.test.ts diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index da9a897905b..a8725539782 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -113,7 +113,7 @@ export namespace ToolRegistry { TaskTool, WebFetchTool, TodoWriteTool, - // TodoReadTool, + TodoReadTool, WebSearchTool, CodeSearchTool, SkillTool, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 14ecea10758..42bfd214206 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -74,16 +74,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, ...(hasTaskPermission ? [] : [ @@ -95,7 +85,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { ]), ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", - action: "allow" as const, + action: "deny" as const, permission: t, })) ?? []), ], @@ -125,6 +115,10 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.abort.addEventListener("abort", cancel) using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) + const toolOverrides = { + ...(hasTaskPermission ? {} : { task: false }), + ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), + } const result = await SessionPrompt.prompt({ messageID, @@ -134,12 +128,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { providerID: model.providerID, }, agent: agent.name, - tools: { - todowrite: false, - todoread: false, - ...(hasTaskPermission ? {} : { task: false }), - ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), - }, + ...(Object.keys(toolOverrides).length > 0 ? { tools: toolOverrides } : {}), parts: promptParts, }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12caf..1d83c19c1f3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -6,6 +6,19 @@ import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" describe("tool.registry", () => { + test("registers built-in todo tools", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("todowrite") + expect(ids).toContain("todoread") + }, + }) + }) + test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts new file mode 100644 index 00000000000..e38d318b2f6 --- /dev/null +++ b/packages/opencode/test/tool/task.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { TaskTool } from "../../src/tool/task" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import { MessageV2 } from "../../src/session/message-v2" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, SessionID } from "../../src/session/schema" +import type { Agent as AgentNamespace } from "../../src/agent/agent" + +const ctx = { + sessionID: SessionID.make("ses_parent"), + messageID: MessageID.make("msg_parent"), + callID: "call-task", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.task", () => { + test("does not hard-disable todo tools for subagents that allow them", async () => { + const subagent = { + name: "todo-enabled-subagent", + mode: "subagent", + options: {}, + permission: [ + { permission: "todowrite", pattern: "*", action: "allow" }, + { permission: "todoread", pattern: "*", action: "allow" }, + ], + } satisfies AgentNamespace.Info + + let createdSessionInput: any + let promptInput: any + + const listSpy = spyOn(Agent, "list").mockResolvedValue([subagent]) + const getSpy = spyOn(Agent, "get").mockImplementation((async () => subagent) as any) + const configSpy = spyOn(Config, "get").mockResolvedValue({ experimental: {} } as any) + const createSpy = spyOn(Session, "create").mockImplementation((async (input: any) => { + createdSessionInput = input + return { id: SessionID.make("ses_child") } as any + }) as any) + const messageSpy = spyOn(MessageV2, "get").mockResolvedValue({ + info: { + role: "assistant", + modelID: "gpt-test", + providerID: "openai", + }, + parts: [], + } as any) + const resolveSpy = spyOn(SessionPrompt, "resolvePromptParts").mockResolvedValue([ + { type: "text", text: "diagnose" }, + ] as any) + const promptSpy = spyOn(SessionPrompt, "prompt").mockImplementation((async (input: any) => { + promptInput = input + return { + parts: [{ type: "text", text: "done" }], + } as any + }) as any) + + try { + const tool = await TaskTool.init() + await tool.execute( + { + description: "Diagnose cluster", + prompt: "Use todo tools if available", + subagent_type: subagent.name, + }, + ctx as any, + ) + + expect(createdSessionInput?.permission?.some((rule: any) => rule.permission === "todowrite")).toBe(false) + expect(createdSessionInput?.permission?.some((rule: any) => rule.permission === "todoread")).toBe(false) + expect(promptInput?.tools?.todowrite).toBeUndefined() + expect(promptInput?.tools?.todoread).toBeUndefined() + expect(promptInput?.tools?.task).toBe(false) + } finally { + promptSpy.mockRestore() + resolveSpy.mockRestore() + messageSpy.mockRestore() + createSpy.mockRestore() + configSpy.mockRestore() + getSpy.mockRestore() + listSpy.mockRestore() + } + }) +})