From 88be194805577664d3517129cc0e3f58df5e8ed3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Feb 2026 22:39:28 +0900 Subject: [PATCH] feat(agent-teams): add read_inbox and read_config tools - Add simple read_inbox tool as thin wrapper over readInbox store function - Add simple read_config tool as thin wrapper over readTeamConfig store function - Both tools support basic filtering (unread_only for inbox, none for config) - Comprehensive test coverage with TDD approach - Tools are separate from registered read_inbox/read_config (which have authorization) --- src/tools/agent-teams/config-tools.test.ts | 87 ++++++++++ src/tools/agent-teams/config-tools.ts | 24 +++ src/tools/agent-teams/inbox-tools.test.ts | 182 +++++++++++++++++++++ src/tools/agent-teams/inbox-tools.ts | 29 ++++ 4 files changed, 322 insertions(+) create mode 100644 src/tools/agent-teams/config-tools.test.ts create mode 100644 src/tools/agent-teams/config-tools.ts create mode 100644 src/tools/agent-teams/inbox-tools.test.ts create mode 100644 src/tools/agent-teams/inbox-tools.ts diff --git a/src/tools/agent-teams/config-tools.test.ts b/src/tools/agent-teams/config-tools.test.ts new file mode 100644 index 00000000..fadeec00 --- /dev/null +++ b/src/tools/agent-teams/config-tools.test.ts @@ -0,0 +1,87 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { createTeamConfig, deleteTeamData } from "./team-config-store" +import { createReadConfigTool } from "./config-tools" + +describe("read_config tool", () => { + let originalCwd: string + let tempProjectDir: string + let teamName: string + const TEST_SESSION_ID = "test-session-123" + const TEST_ABORT_CONTROLLER = new AbortController() + const TEST_CONTEXT = { + sessionID: TEST_SESSION_ID, + messageID: "test-message-123", + agent: "test-agent", + abort: TEST_ABORT_CONTROLLER.signal, + } + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-tools-")) + process.chdir(tempProjectDir) + teamName = `test-team-${randomUUID()}` + }) + + afterEach(() => { + try { + deleteTeamData(teamName) + } catch { + // ignore + } + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + describe("read config action", () => { + test("returns team config when team exists", async () => { + //#given + const config = createTeamConfig(teamName, "Test team", TEST_SESSION_ID, "/tmp", "claude-opus-4-6") + const tool = createReadConfigTool() + + //#when + const resultStr = await tool.execute({ + team_name: teamName, + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.name).toBe(teamName) + expect(result.description).toBe("Test team") + expect(result.members).toHaveLength(1) + expect(result.members[0].name).toBe("team-lead") + expect(result.members[0].agentType).toBe("team-lead") + }) + + test("returns error for non-existent team", async () => { + //#given + const tool = createReadConfigTool() + + //#when + const resultStr = await tool.execute({ + team_name: "nonexistent-team-12345", + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("team_not_found") + }) + + test("requires team_name parameter", async () => { + //#given + const tool = createReadConfigTool() + + //#when + const resultStr = await tool.execute({}, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + }) + }) +}) \ No newline at end of file diff --git a/src/tools/agent-teams/config-tools.ts b/src/tools/agent-teams/config-tools.ts new file mode 100644 index 00000000..649fd878 --- /dev/null +++ b/src/tools/agent-teams/config-tools.ts @@ -0,0 +1,24 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { readTeamConfig } from "./team-config-store" +import { ReadConfigInputSchema } from "./types" + +export function createReadConfigTool(): ToolDefinition { + return tool({ + description: "Read team configuration and member list.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record): Promise => { + try { + const input = ReadConfigInputSchema.parse(args) + const config = readTeamConfig(input.team_name) + if (!config) { + return JSON.stringify({ error: "team_not_found" }) + } + return JSON.stringify(config) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "read_config_failed" }) + } + }, + }) +} \ No newline at end of file diff --git a/src/tools/agent-teams/inbox-tools.test.ts b/src/tools/agent-teams/inbox-tools.test.ts new file mode 100644 index 00000000..ccc4cb51 --- /dev/null +++ b/src/tools/agent-teams/inbox-tools.test.ts @@ -0,0 +1,182 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { appendInboxMessage, ensureInbox } from "./inbox-store" +import { deleteTeamData } from "./team-config-store" +import { createReadInboxTool } from "./inbox-tools" + +describe("read_inbox tool", () => { + let originalCwd: string + let tempProjectDir: string + let teamName: string + const TEST_SESSION_ID = "test-session-123" + const TEST_ABORT_CONTROLLER = new AbortController() + const TEST_CONTEXT = { + sessionID: TEST_SESSION_ID, + messageID: "test-message-123", + agent: "test-agent", + abort: TEST_ABORT_CONTROLLER.signal, + } + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-tools-")) + process.chdir(tempProjectDir) + teamName = `test-team-${randomUUID()}` + }) + + afterEach(() => { + try { + deleteTeamData(teamName) + } catch { + // ignore + } + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + describe("read inbox action", () => { + test("returns all messages when no filters", async () => { + //#given + ensureInbox(teamName, "team-lead") + appendInboxMessage(teamName, "team-lead", { + id: "msg-1", + type: "message", + sender: "user", + recipient: "team-lead", + content: "Hello", + timestamp: new Date().toISOString(), + read: false, + }) + appendInboxMessage(teamName, "team-lead", { + id: "msg-2", + type: "message", + sender: "user", + recipient: "team-lead", + content: "World", + timestamp: new Date().toISOString(), + read: true, + }) + + const tool = createReadInboxTool() + + //#when + const resultStr = await tool.execute({ + team_name: teamName, + agent_name: "team-lead", + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveLength(2) + expect(result[0].id).toBe("msg-1") + expect(result[1].id).toBe("msg-2") + }) + + test("returns only unread messages when unread_only is true", async () => { + //#given + ensureInbox(teamName, "team-lead") + appendInboxMessage(teamName, "team-lead", { + id: "msg-1", + type: "message", + sender: "user", + recipient: "team-lead", + content: "Hello", + timestamp: new Date().toISOString(), + read: false, + }) + appendInboxMessage(teamName, "team-lead", { + id: "msg-2", + type: "message", + sender: "user", + recipient: "team-lead", + content: "World", + timestamp: new Date().toISOString(), + read: true, + }) + + const tool = createReadInboxTool() + + //#when + const resultStr = await tool.execute({ + team_name: teamName, + agent_name: "team-lead", + unread_only: true, + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveLength(1) + expect(result[0].id).toBe("msg-1") + }) + + test("marks messages as read when mark_as_read is true", async () => { + //#given + ensureInbox(teamName, "team-lead") + appendInboxMessage(teamName, "team-lead", { + id: "msg-1", + type: "message", + sender: "user", + recipient: "team-lead", + content: "Hello", + timestamp: new Date().toISOString(), + read: false, + }) + + const tool = createReadInboxTool() + + //#when + await tool.execute({ + team_name: teamName, + agent_name: "team-lead", + mark_as_read: true, + }, TEST_CONTEXT) + + // Read again to check if marked as read + const resultStr = await tool.execute({ + team_name: teamName, + agent_name: "team-lead", + unread_only: true, + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveLength(0) // Should be marked as read + }) + + test("returns empty array for non-existent inbox", async () => { + //#given + const tool = createReadInboxTool() + + //#when + const resultStr = await tool.execute({ + team_name: "nonexistent", + agent_name: "team-lead", + }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toEqual([]) + }) + + test("requires team_name and agent_name parameters", async () => { + //#given + const tool = createReadInboxTool() + + //#when + const resultStr = await tool.execute({}, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + }) + }) +}) \ No newline at end of file diff --git a/src/tools/agent-teams/inbox-tools.ts b/src/tools/agent-teams/inbox-tools.ts new file mode 100644 index 00000000..fe8de3e9 --- /dev/null +++ b/src/tools/agent-teams/inbox-tools.ts @@ -0,0 +1,29 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { readInbox } from "./inbox-store" +import { ReadInboxInputSchema } from "./types" + +export function createReadInboxTool(): ToolDefinition { + return tool({ + description: "Read inbox messages for a team member.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Member name"), + unread_only: tool.schema.boolean().optional().describe("Return only unread messages"), + mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"), + }, + execute: async (args: Record): Promise => { + try { + const input = ReadInboxInputSchema.parse(args) + const messages = readInbox( + input.team_name, + input.agent_name, + input.unread_only ?? false, + input.mark_as_read ?? false, + ) + return JSON.stringify(messages) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" }) + } + }, + }) +} \ No newline at end of file