diff --git a/.gitignore b/.gitignore index 212fbbe008..c3bd7522a0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ test-injection/ notepad.md oauth-success.html *.bun-build + +# Runtime data +.arbor/ +.oc-state/ +/mcb.* diff --git a/docs/mcb-integration.md b/docs/mcb-integration.md new file mode 100644 index 0000000000..66de7b33b5 --- /dev/null +++ b/docs/mcb-integration.md @@ -0,0 +1,286 @@ +# MCB Integration Guide + +Memory Context Bank (MCB) integration for oh-my-opencode. Provides semantic code search, persistent memory, code validation, and version control awareness across sessions. + +**MCB is completely optional.** Oh-my-opencode works perfectly without it. When disabled (the default), MCB has zero impact on the plugin. + +## Prerequisites + +- MCB binary installed (`mcb` v0.2.1+) — [github.com/marlonsc/mcb](https://github.com/marlonsc/mcb) +- oh-my-opencode v3.4.0+ + +## Quick Start + +Add to your `.opencode/oh-my-opencode.json` (project-level) or `~/.config/opencode/oh-my-opencode.json` (user-level): + +```jsonc +{ + "mcb": { + "enabled": true + } +} +``` + +That's it. MCB connects via stdio (`mcb serve`) by default. All MCB tools are enabled when `enabled` is `true`. + +MCB is also registered as a builtin skill (`oc-mcb`), which means agents can load it via `load_skills=["oc-mcb"]` to get access to MCB tools. + +## Configuration Reference + +All fields are optional. MCB is disabled unless `enabled` is explicitly set to `true`. + +```jsonc +{ + "mcb": { + // Master switch. Must be true to activate MCB integration. + // Default: undefined (treated as disabled) + "enabled": true, + + // MCB binary command. Default: "mcb" + "command": "mcb", + + // Arguments passed to the command. Default: ["serve"] + "args": ["serve"], + + // Environment variables for the MCB process. + "env": {}, + + // MCB data directory override. + "data_dir": "/path/to/mcb-data", + + // MCB server URL (legacy/alternative — for HTTP transport). + "url": "http://localhost:3100", + + // Default collection for search and indexing operations. + "default_collection": "my-project", + + // Automatically index the project on plugin startup. + "auto_index": false, + + // Per-tool toggles. Each defaults to true when MCB is enabled. + "tools": { + "search": true, // Semantic code search across indexed repositories + "memory": true, // Persistent observations, error patterns, quality gates + "index": true, // Codebase indexing for semantic search + "validate": true, // Code quality validation (12 built-in rules) + "vcs": true, // Git-aware context (branch comparison, impact analysis) + "session": true // Session lifecycle tracking + } + } +} +``` + +### Field Details + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | `boolean` | `undefined` | Master switch. MCB is inactive unless explicitly `true`. | +| `command` | `string` | `"mcb"` | MCB binary command for stdio transport. | +| `args` | `string[]` | `["serve"]` | Arguments passed to the MCB command. | +| `env` | `Record` | - | Environment variables for the MCB process. | +| `data_dir` | `string` | - | Override MCB data directory. | +| `url` | `string` (URL) | - | MCB server endpoint (legacy — for HTTP transport). | +| `default_collection` | `string` | - | Default collection name for search/index operations. | +| `auto_index` | `boolean` | - | Auto-index project codebase on plugin startup. | +| `tools.search` | `boolean` | `true` | Enable semantic code search (`mcb_search`). | +| `tools.memory` | `boolean` | `true` | Enable persistent memory (`mcb_memory`). | +| `tools.index` | `boolean` | `true` | Enable codebase indexing (`mcb_index`). | +| `tools.validate` | `boolean` | `true` | Enable code validation (`mcb_validate`). | +| `tools.vcs` | `boolean` | `true` | Enable git-aware context (`mcb_vcs`). | +| `tools.session` | `boolean` | `true` | Enable session tracking (`mcb_session`). | + +## Recommended Configurations + +### Minimal (Search + Memory) + +Best for: individual developers who want semantic search and cross-session memory without the overhead of full integration. + +```jsonc +{ + "mcb": { + "enabled": true, + "tools": { + "search": true, + "memory": true, + "index": false, + "validate": false, + "vcs": false, + "session": false + } + } +} +``` + +### Development (Search + Memory + Index + Validate) + +Best for: active development workflows where you want code quality checks and automatic indexing alongside search. + +```jsonc +{ + "mcb": { + "enabled": true, + "auto_index": true, + "tools": { + "search": true, + "memory": true, + "index": true, + "validate": true, + "vcs": false, + "session": false + } + } +} +``` + +### Full (All Tools) + +Best for: teams or power users who want the complete MCB experience including git-aware context and session tracking. + +```jsonc +{ + "mcb": { + "enabled": true, + "default_collection": "my-project", + "auto_index": true, + "tools": { + "search": true, + "memory": true, + "index": true, + "validate": true, + "vcs": true, + "session": true + } + } +} +``` + +## Architecture + +### Transport + +MCB connects via **stdio** by default. When an agent loads the `oc-mcb` skill or a tool call targets MCB, the `SkillMcpManager` spawns `mcb serve` as a child process and communicates over stdin/stdout using the MCP protocol. + +The connection is **lazy** — the MCB process is only spawned on the first tool call, not at plugin startup. This means zero overhead when MCB is enabled but not used. + +### Config Gate + +When oh-my-opencode starts, it reads the `mcb` config and makes a one-time decision: + +1. **`enabled` is falsy or missing**: MCB is permanently disabled for the session. All MCB tool calls return gracefully with no-op results. Zero overhead. +2. **`enabled` is `true`**: Per-tool toggles are evaluated. Disabled tools are marked unavailable. The availability state is then **locked** for the rest of the session. + +This lock-on-startup design means: +- No runtime configuration drift +- No unexpected MCB calls mid-session +- Deterministic behavior from the moment the plugin loads + +### Graceful Degradation + +Every MCB operation is wrapped in a fallback layer. If an MCB call fails at runtime (binary not found, process crash, unexpected error): + +- The operation returns a degraded result instead of throwing +- The specific tool that failed is automatically marked unavailable for subsequent calls +- A one-time warning is emitted describing which capabilities are affected +- Failed operations are queued to disk for later replay +- The rest of oh-my-opencode continues working normally + +This means MCB issues will never crash your session. At worst, you lose MCB features while everything else keeps working. + +### Recovery + +On `session.created`, oh-my-opencode automatically: +1. Resets degradation warnings (so they can re-emit in the new session) +2. Attempts to replay any queued operations from previous failed MCB calls (fire-and-forget, non-blocking) + +## MCB Tools Reference + +### `mcb_search` (search) + +Semantic search across indexed code, memory observations, and context. + +| Resource | Description | +|----------|-------------| +| `code` | Search indexed source code semantically | +| `memory` | Search stored observations and patterns | +| `context` | Search project context and metadata | + +### `mcb_memory` (memory) + +Store and retrieve persistent observations across sessions. + +| Resource Type | Description | +|---------------|-------------| +| `observation` | General observations about codebase | +| `execution` | Execution logs and outcomes | +| `quality_gate` | Quality check results | +| `error_pattern` | Recurring error patterns | +| `session` | Session-level context | + +### `mcb_index` (index) + +Index project source code for semantic search. + +| Action | Description | +|--------|-------------| +| `start` | Begin indexing a directory | +| `status` | Check indexing progress | +| `clear` | Remove indexed data | + +### `mcb_validate` (validate) + +Run code quality validation with 12 built-in rules. + +| Action | Description | +|--------|-------------| +| `run` | Validate a file or project | +| `list_rules` | Show available validation rules | +| `analyze` | Deep analysis with complexity metrics | + +### `mcb_vcs` (vcs) + +Git-aware context and impact analysis. + +Provides repository listing, branch comparison, and change impact analysis. + +### `mcb_session` (session) + +Session lifecycle management for tracking work across conversations. + +## Tuning Tips + +1. **Start minimal.** Enable only `search` and `memory` first. Add tools as you find value in them. + +2. **Use `auto_index` for active projects.** If you're working on the same codebase daily, auto-indexing on startup ensures search results stay fresh. + +3. **Disable `session` if you don't need cross-session tracking.** Session tracking adds overhead and is most useful for teams tracking work across multiple developers. + +4. **Use `default_collection` to namespace projects.** If you work on multiple projects against the same MCB server, set a unique collection name per project config. + +5. **Disable `validate` if you have existing linters.** MCB validation is useful when you don't have ESLint/Biome configured, but redundant if you already have a lint pipeline. + +6. **Keep `vcs` disabled unless you need cross-branch analysis.** The built-in git tools in oh-my-opencode handle most git operations. MCB VCS adds value when comparing branches or analyzing change impact across large codebases. + +## Troubleshooting + +### MCB tools not appearing + +- Verify `"enabled": true` is set in your config +- Check that `mcb` is in your PATH: `which mcb` +- Check that your config file is in the correct location (`.opencode/oh-my-opencode.json` or `~/.config/opencode/oh-my-opencode.json`) +- Restart your opencode session (MCB config is evaluated once at startup) + +### MCB calls returning degraded results + +- Check that the MCB binary is installed and runnable: `mcb --version` +- If using a custom binary path, set `"command": "/path/to/mcb"` in config +- If a tool was auto-disabled due to errors, restart the session to re-enable it + +### Config changes not taking effect + +MCB availability is locked at startup. Any config changes require restarting the opencode session to take effect. This is by design to ensure consistent behavior within a session. + +### Specific tools not working + +- Check the `tools` section in your config — individual tools can be disabled +- Some tools have known limitations (see AGENTS.md for current MCB tool status) +- Ensure the MCB binary version supports the tool you're trying to use (v0.2.1+ recommended) diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index bb5f6bdb0b..86862666c5 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -21,6 +21,7 @@ export const HookNameSchema = z.enum([ "auto-update-checker", "startup-toast", "keyword-detector", + "ambiguity-detector", "agent-usage-reminder", "non-interactive-env", "interactive-bash-session", @@ -46,6 +47,7 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", + "wisdom-capture", ]) export type HookName = z.infer diff --git a/src/config/schema/mcb.ts b/src/config/schema/mcb.ts new file mode 100644 index 0000000000..36174f9969 --- /dev/null +++ b/src/config/schema/mcb.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +export const McbConfigSchema = z.object({ + enabled: z.boolean().optional(), + url: z.string().url().optional(), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + data_dir: z.string().optional(), + default_collection: z.string().optional(), + auto_index: z.boolean().optional(), + tools: z + .object({ + search: z.boolean().optional(), + memory: z.boolean().optional(), + index: z.boolean().optional(), + validate: z.boolean().optional(), + vcs: z.boolean().optional(), + session: z.boolean().optional(), + }) + .optional(), +}) + +export type McbConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index be0ebd9149..3869357ad8 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" import { HookNameSchema } from "./hooks" +import { McbConfigSchema } from "./mcb" import { NotificationConfigSchema } from "./notification" import { RalphLoopConfigSchema } from "./ralph-loop" import { SkillsConfigSchema } from "./skills" @@ -50,6 +51,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ websearch: WebsearchConfigSchema.optional(), tmux: TmuxConfigSchema.optional(), sisyphus: SisyphusConfigSchema.optional(), + mcb: McbConfigSchema.optional(), /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ _migrations: z.array(z.string()).optional(), }) diff --git a/src/features/artifact-detection/index.ts b/src/features/artifact-detection/index.ts new file mode 100644 index 0000000000..84a9c31bbc --- /dev/null +++ b/src/features/artifact-detection/index.ts @@ -0,0 +1,10 @@ +export { scanArtifacts } from "./scanner" +export type { ScanOptions } from "./scanner" +export { ingestArtifacts } from "./ingestion" +export type { IngestionResult } from "./ingestion" +export type { + ArtifactClass, + DetectedArtifact, + ArtifactScanResult, + IngestionRecord, +} from "./types" diff --git a/src/features/artifact-detection/ingestion.test.ts b/src/features/artifact-detection/ingestion.test.ts new file mode 100644 index 0000000000..451ac19263 --- /dev/null +++ b/src/features/artifact-detection/ingestion.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, rmSync, existsSync, readFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { resetMcbAvailability } from "../mcb-integration" +import { ingestArtifacts } from "./ingestion" +import type { DetectedArtifact } from "./types" + +function makeArtifact(overrides: Partial = {}): DetectedArtifact { + return { + class: "sisyphus-plan", + path: "/tmp/test/plan.md", + relativePath: "plan.md", + contentHash: "abcdef1234567890", + detectedAt: Date.now(), + sizeBytes: 100, + ...overrides, + } +} + +describe("artifact-detection/ingestion", () => { + const TEST_DIR = join(tmpdir(), "artifact-ingestion-test-" + Date.now()) + + beforeEach(() => { + resetMcbAvailability() + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }) + } + mkdirSync(join(TEST_DIR, ".sisyphus"), { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + //#given new artifacts with no prior ingestion + //#when ingestArtifacts is called without storeOperation + //#then all artifacts are ingested and hashes are persisted + it("ingests new artifacts and persists hashes", async () => { + const artifacts = [ + makeArtifact({ contentHash: "hash_a_1234567890" }), + makeArtifact({ contentHash: "hash_b_1234567890", relativePath: "plan2.md" }), + ] + + const result = await ingestArtifacts(artifacts, TEST_DIR, "test-collection") + expect(result.ingested).toBe(2) + expect(result.skipped).toBe(0) + expect(result.failed).toBe(0) + + const hashFile = join(TEST_DIR, ".sisyphus", ".ingested-hashes.json") + expect(existsSync(hashFile)).toBe(true) + const records = JSON.parse(readFileSync(hashFile, "utf-8")) + expect(records).toHaveLength(2) + }) + + //#given artifacts already ingested + //#when ingestArtifacts is called with same hashes + //#then duplicates are skipped + it("skips already-ingested artifacts", async () => { + const artifacts = [makeArtifact({ contentHash: "already_ingested1" })] + + await ingestArtifacts(artifacts, TEST_DIR, "test-collection") + const result = await ingestArtifacts(artifacts, TEST_DIR, "test-collection") + + expect(result.ingested).toBe(0) + expect(result.skipped).toBe(1) + }) + + //#given artifacts with a storeOperation that succeeds + //#when ingestArtifacts is called + //#then it calls storeOperation for each new artifact + it("calls storeOperation for each new artifact", async () => { + const stored: DetectedArtifact[] = [] + const storeOp = async (artifact: DetectedArtifact) => { + stored.push(artifact) + } + + const artifacts = [ + makeArtifact({ contentHash: "store_test_00001" }), + makeArtifact({ contentHash: "store_test_00002", relativePath: "plan2.md" }), + ] + + const result = await ingestArtifacts(artifacts, TEST_DIR, "test-collection", storeOp) + expect(result.ingested).toBe(2) + expect(stored).toHaveLength(2) + }) + + //#given a storeOperation that throws + //#when ingestArtifacts is called + //#then the failed artifact is recorded with error details + it("records store operation failure", async () => { + const storeOp = async (_artifact: DetectedArtifact) => { + throw new Error("connection timeout") + } + + const artifacts = [makeArtifact({ contentHash: "fail_test_00001" })] + + const result = await ingestArtifacts(artifacts, TEST_DIR, "test-collection", storeOp) + expect(result.failed).toBe(1) + expect(result.ingested).toBe(0) + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toContain("connection timeout") + }) + + //#given a mix of new and previously-ingested artifacts + //#when ingestArtifacts is called + //#then only new ones are processed + it("only processes new artifacts in a mixed batch", async () => { + const existing = [makeArtifact({ contentHash: "existing_hash_01" })] + await ingestArtifacts(existing, TEST_DIR, "test-collection") + + const mixed = [ + makeArtifact({ contentHash: "existing_hash_01" }), + makeArtifact({ contentHash: "brand_new_hash_01", relativePath: "new.md" }), + ] + + const result = await ingestArtifacts(mixed, TEST_DIR, "test-collection") + expect(result.skipped).toBe(1) + expect(result.ingested).toBe(1) + }) +}) diff --git a/src/features/artifact-detection/ingestion.ts b/src/features/artifact-detection/ingestion.ts new file mode 100644 index 0000000000..09657bd57d --- /dev/null +++ b/src/features/artifact-detection/ingestion.ts @@ -0,0 +1,92 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { join, dirname } from "path" +import { withMcbFallback } from "../mcb-integration" +import type { DetectedArtifact, IngestionRecord } from "./types" + +const HASH_FILE = ".sisyphus/.ingested-hashes.json" + +function loadIngestedHashes(projectDir: string): Set { + const hashPath = join(projectDir, HASH_FILE) + try { + if (existsSync(hashPath)) { + const records: IngestionRecord[] = JSON.parse(readFileSync(hashPath, "utf-8")) + return new Set(records.map((r) => r.artifactHash)) + } + } catch { + // corrupt file — start fresh + } + return new Set() +} + +function saveIngestedHash(projectDir: string, record: IngestionRecord): void { + const hashPath = join(projectDir, HASH_FILE) + try { + const dir = dirname(hashPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const existing: IngestionRecord[] = existsSync(hashPath) + ? JSON.parse(readFileSync(hashPath, "utf-8")) + : [] + existing.push(record) + writeFileSync(hashPath, JSON.stringify(existing, null, 2)) + } catch { + // non-critical — idempotency will still work via in-memory set + } +} + +export interface IngestionResult { + ingested: number + skipped: number + failed: number + errors: string[] +} + +export async function ingestArtifacts( + artifacts: DetectedArtifact[], + projectDir: string, + collection: string, + storeOperation?: (artifact: DetectedArtifact) => Promise, +): Promise { + const ingestedHashes = loadIngestedHashes(projectDir) + const result: IngestionResult = { ingested: 0, skipped: 0, failed: 0, errors: [] } + + for (const artifact of artifacts) { + if (ingestedHashes.has(artifact.contentHash)) { + result.skipped++ + continue + } + + if (storeOperation) { + const mcbResult = await withMcbFallback( + () => storeOperation(artifact), + "memory", + { + tool: "memory", + action: "store", + params: { + relativePath: artifact.relativePath, + contentHash: artifact.contentHash, + }, + maxRetries: 3, + source: "artifact-ingestion", + }, + projectDir, + ) + + if (!mcbResult.success) { + result.failed++ + result.errors.push(`${artifact.relativePath}: ${mcbResult.error}`) + continue + } + } + + ingestedHashes.add(artifact.contentHash) + saveIngestedHash(projectDir, { + artifactHash: artifact.contentHash, + ingestedAt: Date.now(), + mcbCollection: collection, + }) + result.ingested++ + } + + return result +} diff --git a/src/features/artifact-detection/scanner.test.ts b/src/features/artifact-detection/scanner.test.ts new file mode 100644 index 0000000000..22540b1298 --- /dev/null +++ b/src/features/artifact-detection/scanner.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { scanArtifacts } from "./scanner" + +describe("artifact-detection/scanner", () => { + const TEST_DIR = join(tmpdir(), "artifact-scanner-test-" + Date.now()) + const FAKE_HOME = join(TEST_DIR, "fake-home") + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(FAKE_HOME, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + //#given an empty project directory + //#when scanArtifacts is called + //#then it returns zero artifacts with no errors + it("returns empty results for empty project", () => { + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + expect(result.artifacts).toHaveLength(0) + expect(result.errors).toHaveLength(0) + expect(result.projectDir).toBe(TEST_DIR) + expect(result.scanDuration).toBeGreaterThanOrEqual(0) + }) + + //#given a project with a boulder.json + //#when scanArtifacts is called + //#then it detects the boulder plan artifact + it("detects boulder.json as boulder-plan", () => { + const sisyphusDir = join(TEST_DIR, ".sisyphus") + mkdirSync(sisyphusDir, { recursive: true }) + writeFileSync(join(sisyphusDir, "boulder.json"), '{"active_plan": "test"}') + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + expect(result.artifacts).toHaveLength(1) + expect(result.artifacts[0].class).toBe("boulder-plan") + expect(result.artifacts[0].relativePath).toBe(".sisyphus/boulder.json") + expect(result.artifacts[0].contentHash).toHaveLength(16) + expect(result.artifacts[0].sizeBytes).toBeGreaterThan(0) + }) + + //#given a project with plan markdown files + //#when scanArtifacts is called + //#then it detects them as sisyphus-plan artifacts + it("detects .sisyphus/plans/*.md as sisyphus-plan", () => { + const plansDir = join(TEST_DIR, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n\nStep 1") + writeFileSync(join(plansDir, "another.md"), "# Another\n\nStep 2") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + const plans = result.artifacts.filter((a) => a.class === "sisyphus-plan") + expect(plans).toHaveLength(2) + }) + + //#given a project with draft files + //#when scanArtifacts is called + //#then it detects them as sisyphus-draft artifacts + it("detects .sisyphus/drafts/*.md as sisyphus-draft", () => { + const draftsDir = join(TEST_DIR, ".sisyphus", "drafts") + mkdirSync(draftsDir, { recursive: true }) + writeFileSync(join(draftsDir, "spec-v1.md"), "# Draft spec") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + const drafts = result.artifacts.filter((a) => a.class === "sisyphus-draft") + expect(drafts).toHaveLength(1) + expect(drafts[0].relativePath).toBe(".sisyphus/drafts/spec-v1.md") + }) + + //#given a project with .opencode/skills/*/SKILL.md files + //#when scanArtifacts is called + //#then it detects them recursively as opencode-skill artifacts + it("detects .opencode/skills/*/SKILL.md recursively", () => { + const skillDir = join(TEST_DIR, ".opencode", "skills", "my-skill") + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: my-skill\n---\n# Skill") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + const skills = result.artifacts.filter((a) => a.class === "opencode-skill") + expect(skills).toHaveLength(1) + expect(skills[0].relativePath).toContain("SKILL.md") + }) + + //#given context files in ~/.config/opencode/context/ + //#when scanArtifacts is called with homeDir option + //#then it detects them as context-file artifacts + it("detects context files from homeDir/.config/opencode/context/", () => { + const contextDir = join(FAKE_HOME, ".config", "opencode", "context") + mkdirSync(contextDir, { recursive: true }) + writeFileSync(join(contextDir, "project-notes.md"), "# Notes") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + const contexts = result.artifacts.filter((a) => a.class === "context-file") + expect(contexts).toHaveLength(1) + }) + + //#given a project with multiple artifact types + //#when scanArtifacts is called + //#then it detects all types in one scan + it("detects multiple artifact types in single scan", () => { + const sisyphusDir = join(TEST_DIR, ".sisyphus") + const plansDir = join(sisyphusDir, "plans") + const skillDir = join(TEST_DIR, ".opencode", "skills", "test-skill") + mkdirSync(plansDir, { recursive: true }) + mkdirSync(skillDir, { recursive: true }) + + writeFileSync(join(sisyphusDir, "boulder.json"), '{"active": true}') + writeFileSync(join(plansDir, "plan.md"), "# Plan") + writeFileSync(join(skillDir, "SKILL.md"), "# Skill") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + const classes = result.artifacts.map((a) => a.class) + expect(classes).toContain("boulder-plan") + expect(classes).toContain("sisyphus-plan") + expect(classes).toContain("opencode-skill") + expect(result.artifacts.length).toBeGreaterThanOrEqual(3) + }) + + //#given each artifact has content + //#when scanned + //#then contentHash is a 16-char hex string + it("generates consistent 16-char hex content hashes", () => { + const sisyphusDir = join(TEST_DIR, ".sisyphus") + mkdirSync(sisyphusDir, { recursive: true }) + writeFileSync(join(sisyphusDir, "boulder.json"), '{"test": true}') + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + expect(result.artifacts[0].contentHash).toMatch(/^[a-f0-9]{16}$/) + }) + + //#given non-matching files exist in scan targets + //#when scanArtifacts is called + //#then it ignores them + it("ignores non-matching files", () => { + const sisyphusDir = join(TEST_DIR, ".sisyphus") + mkdirSync(sisyphusDir, { recursive: true }) + writeFileSync(join(sisyphusDir, "notes.txt"), "random notes") + writeFileSync(join(sisyphusDir, "config.yaml"), "key: value") + + const result = scanArtifacts(TEST_DIR, { homeDir: FAKE_HOME }) + expect(result.artifacts).toHaveLength(0) + }) +}) diff --git a/src/features/artifact-detection/scanner.ts b/src/features/artifact-detection/scanner.ts new file mode 100644 index 0000000000..6be61c77c4 --- /dev/null +++ b/src/features/artifact-detection/scanner.ts @@ -0,0 +1,91 @@ +import { createHash } from "crypto" +import { readFileSync, readdirSync, statSync, existsSync } from "fs" +import { join, relative, resolve } from "path" +import { homedir } from "os" +import type { ArtifactClass, ArtifactScanResult, DetectedArtifact } from "./types" + +interface ScanTarget { + dir: string + pattern: RegExp + artifactClass: ArtifactClass + recursive?: boolean +} + +function hashContent(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16) +} + +function collectFiles(dir: string, pattern: RegExp, recursive: boolean): string[] { + if (!existsSync(dir)) return [] + const results: string[] = [] + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name) + if (entry.isFile() && pattern.test(entry.name)) { + results.push(fullPath) + } else if (entry.isDirectory() && recursive) { + results.push(...collectFiles(fullPath, pattern, true)) + } + } + } catch { + /* intentionally empty — unreadable directories are skipped */ + } + return results +} + +function detectArtifact(filePath: string, artifactClass: ArtifactClass, projectDir: string): DetectedArtifact | null { + try { + const stat = statSync(filePath) + const content = readFileSync(filePath, "utf-8") + return { + class: artifactClass, + path: filePath, + relativePath: relative(projectDir, filePath), + contentHash: hashContent(content), + detectedAt: Date.now(), + sizeBytes: stat.size, + } + } catch { + return null + } +} + +export interface ScanOptions { + homeDir?: string +} + +export function scanArtifacts(projectDir: string, options?: ScanOptions): ArtifactScanResult { + const start = Date.now() + const artifacts: DetectedArtifact[] = [] + const errors: string[] = [] + const resolved = resolve(projectDir) + const home = options?.homeDir ?? homedir() + + const targets: ScanTarget[] = [ + { dir: join(resolved, ".sisyphus"), pattern: /^boulder\.json$/, artifactClass: "boulder-plan" }, + { dir: join(resolved, ".sisyphus", "plans"), pattern: /\.md$/, artifactClass: "sisyphus-plan" }, + { dir: join(resolved, ".sisyphus", "drafts"), pattern: /\.md$/, artifactClass: "sisyphus-draft" }, + { dir: join(home, ".config", "opencode", "context"), pattern: /\.md$/, artifactClass: "context-file", recursive: true }, + { dir: join(home, ".config", "opencode"), pattern: /^hooks\.json$/, artifactClass: "hooks-config" }, + { dir: join(resolved, ".opencode", "skills"), pattern: /^SKILL\.md$/, artifactClass: "opencode-skill", recursive: true }, + ] + + for (const target of targets) { + try { + const files = collectFiles(target.dir, target.pattern, target.recursive ?? false) + for (const file of files) { + const artifact = detectArtifact(file, target.artifactClass, resolved) + if (artifact) artifacts.push(artifact) + } + } catch (err) { + errors.push(`scan error for ${target.artifactClass}: ${err instanceof Error ? err.message : String(err)}`) + } + } + + return { + projectDir: resolved, + artifacts, + scanDuration: Date.now() - start, + errors, + } +} diff --git a/src/features/artifact-detection/types.ts b/src/features/artifact-detection/types.ts new file mode 100644 index 0000000000..bd952cb059 --- /dev/null +++ b/src/features/artifact-detection/types.ts @@ -0,0 +1,29 @@ +export type ArtifactClass = + | "boulder-plan" + | "sisyphus-plan" + | "sisyphus-draft" + | "context-file" + | "hooks-config" + | "opencode-skill" + +export interface DetectedArtifact { + class: ArtifactClass + path: string + relativePath: string + contentHash: string + detectedAt: number + sizeBytes: number +} + +export interface ArtifactScanResult { + projectDir: string + artifacts: DetectedArtifact[] + scanDuration: number + errors: string[] +} + +export interface IngestionRecord { + artifactHash: string + ingestedAt: number + mcbCollection: string +} diff --git a/src/features/builtin-skills/skills.test.ts b/src/features/builtin-skills/skills.test.ts index 33f0cb56fb..c9fbd12157 100644 --- a/src/features/builtin-skills/skills.test.ts +++ b/src/features/builtin-skills/skills.test.ts @@ -61,7 +61,7 @@ describe("createBuiltinSkills", () => { expect(agentBrowserSkill!.template).toContain("agent-browser snapshot") }) - test("always includes frontend-ui-ux and git-master skills", () => { + test("always includes frontend-ui-ux, git-master, and oc-mcb skills", () => { // given - both provider options // when @@ -72,10 +72,11 @@ describe("createBuiltinSkills", () => { for (const skills of [defaultSkills, agentBrowserSkills]) { expect(skills.find((s) => s.name === "frontend-ui-ux")).toBeDefined() expect(skills.find((s) => s.name === "git-master")).toBeDefined() + expect(skills.find((s) => s.name === "oc-mcb")).toBeDefined() } }) - test("returns exactly 4 skills regardless of provider", () => { + test("returns exactly 5 skills regardless of provider", () => { // given // when @@ -83,8 +84,8 @@ describe("createBuiltinSkills", () => { const agentBrowserSkills = createBuiltinSkills({ browserProvider: "agent-browser" }) // then - expect(defaultSkills).toHaveLength(4) - expect(agentBrowserSkills).toHaveLength(4) + expect(defaultSkills).toHaveLength(5) + expect(agentBrowserSkills).toHaveLength(5) }) test("should exclude playwright when it is in disabledSkills", () => { @@ -99,7 +100,7 @@ describe("createBuiltinSkills", () => { expect(skills.map((s) => s.name)).toContain("frontend-ui-ux") expect(skills.map((s) => s.name)).toContain("git-master") expect(skills.map((s) => s.name)).toContain("dev-browser") - expect(skills.length).toBe(3) + expect(skills.length).toBe(4) }) test("should exclude multiple skills when they are in disabledSkills", () => { @@ -114,13 +115,13 @@ describe("createBuiltinSkills", () => { expect(skills.map((s) => s.name)).not.toContain("git-master") expect(skills.map((s) => s.name)).toContain("frontend-ui-ux") expect(skills.map((s) => s.name)).toContain("dev-browser") - expect(skills.length).toBe(2) + expect(skills.length).toBe(3) }) test("should return an empty array when all skills are disabled", () => { // #given const options = { - disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "dev-browser"]), + disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "dev-browser", "oc-mcb"]), } // #when @@ -138,6 +139,6 @@ describe("createBuiltinSkills", () => { const skills = createBuiltinSkills(options) // #then - expect(skills.length).toBe(4) + expect(skills.length).toBe(5) }) }) diff --git a/src/features/builtin-skills/skills.ts b/src/features/builtin-skills/skills.ts index 2f872698f3..b03ba411e9 100644 --- a/src/features/builtin-skills/skills.ts +++ b/src/features/builtin-skills/skills.ts @@ -7,6 +7,7 @@ import { frontendUiUxSkill, gitMasterSkill, devBrowserSkill, + mcbSkill, } from "./skills/index" export interface CreateBuiltinSkillsOptions { @@ -19,7 +20,7 @@ export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): B const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill - const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill] + const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill, mcbSkill] if (!disabledSkills) { return skills diff --git a/src/features/builtin-skills/skills/index.ts b/src/features/builtin-skills/skills/index.ts index fdd79d2536..6e701ebc6c 100644 --- a/src/features/builtin-skills/skills/index.ts +++ b/src/features/builtin-skills/skills/index.ts @@ -2,3 +2,4 @@ export { playwrightSkill, agentBrowserSkill } from "./playwright" export { frontendUiUxSkill } from "./frontend-ui-ux" export { gitMasterSkill } from "./git-master" export { devBrowserSkill } from "./dev-browser" +export { mcbSkill } from "./mcb" diff --git a/src/features/builtin-skills/skills/mcb.ts b/src/features/builtin-skills/skills/mcb.ts new file mode 100644 index 0000000000..270726234a --- /dev/null +++ b/src/features/builtin-skills/skills/mcb.ts @@ -0,0 +1,43 @@ +import type { BuiltinSkill } from "../types" + +export const mcbSkill: BuiltinSkill = { + name: "oc-mcb", + description: + "MCB (Memory Context Browser) integration for semantic code search, session management, and validation. Provides persistent context across sessions via local MCB binary.", + template: `# MCB Integration + +MCB provides semantic code search, memory persistence, and code validation via a local MCP server. + +## Available Tools + +| Tool | Purpose | +|------|---------| +| \`mcp_mcb_search\` | Semantic code search across indexed repositories | +| \`mcp_mcb_memory\` | Store and retrieve observations, learnings, and session data | +| \`mcp_mcb_index\` | Index codebase for semantic search | +| \`mcp_mcb_validate\` | Code quality validation against configurable rules | +| \`mcp_mcb_vcs\` | Git-aware context: branch comparison, impact analysis | + +## Usage + +Search code semantically: +\`\`\` +mcp_mcb_search(resource="code", query="authentication middleware") +\`\`\` + +Store an observation: +\`\`\` +mcp_mcb_memory(action="store", resource="observation", data={...}) +\`\`\` + +Index a repository: +\`\`\` +mcp_mcb_index(action="start", path="/path/to/repo", collection="my-project") +\`\`\``, + mcpConfig: { + mcb: { + command: "mcb", + args: ["serve"], + }, + }, +} diff --git a/src/features/compat-shims/context-normalizer.test.ts b/src/features/compat-shims/context-normalizer.test.ts new file mode 100644 index 0000000000..3c487b2d22 --- /dev/null +++ b/src/features/compat-shims/context-normalizer.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test" +import { normalizeLegacyContext } from "./context-normalizer" +import type { LegacyContextFile } from "./types" + +describe("compat-shims/context-normalizer", () => { + //#given valid legacy markdown with frontmatter + //#when normalizeLegacyContext is called + //#then it uses frontmatter topic, strips frontmatter from content, and normalizes tags + it("normalizes valid legacy context with frontmatter", () => { + const file: LegacyContextFile = { + filename: "agent-routing-patterns.md", + source: "opencode-context", + content: [ + "---", + "topic: Routing", + "tags:", + " - agents", + " - orchestration", + "---", + "# Content", + "Details here", + ].join("\n"), + } + + const result = normalizeLegacyContext(file) + + expect(result.topic).toBe("Routing") + expect(result.content).toContain("# Content") + expect(result.content).not.toContain("topic: Routing") + expect(result.tags).toEqual(["agents", "orchestration", "opencode-context"]) + expect(result.source).toBe("opencode-context") + expect(result.normalizedAt).toBeGreaterThan(0) + }) + + //#given malformed frontmatter + //#when normalizeLegacyContext is called + //#then it falls back to filename-based topic and keeps original content safely + it("handles malformed frontmatter safely", () => { + const file: LegacyContextFile = { + filename: "task-resume.md", + source: "sisyphus-plan", + content: [ + "---", + "topic: [not valid yaml", + "---", + "Body content", + ].join("\n"), + } + + const result = normalizeLegacyContext(file) + + expect(result.topic).toBe("task resume") + expect(result.content).toContain("Body content") + expect(result.tags).toEqual(["sisyphus-plan"]) + }) + + //#given empty filename and empty content + //#when normalizeLegacyContext is called + //#then it returns safe default values + it("handles empty input values", () => { + const file: LegacyContextFile = { + filename: "", + source: "hooks-config", + content: "", + } + + const result = normalizeLegacyContext(file) + + expect(result.topic).toBe("untitled") + expect(result.content).toBe("") + expect(result.tags).toEqual(["hooks-config"]) + expect(result.source).toBe("hooks-config") + }) +}) diff --git a/src/features/compat-shims/context-normalizer.ts b/src/features/compat-shims/context-normalizer.ts new file mode 100644 index 0000000000..f02e6d8370 --- /dev/null +++ b/src/features/compat-shims/context-normalizer.ts @@ -0,0 +1,51 @@ +import { basename } from "path" +import { parseFrontmatter } from "../../shared" +import type { LegacyContextFile, NormalizedContext } from "./types" + +function getTopicFromFilename(filename: string): string { + const base = basename(filename || "", ".md").trim() + if (!base) { + return "untitled" + } + + const normalized = base.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim() + return normalized || "untitled" +} + +function getFrontmatterTopic(data: Record): string | null { + const topic = data.topic + if (typeof topic !== "string") { + return null + } + + const trimmed = topic.trim() + return trimmed ? trimmed : null +} + +function getFrontmatterTags(data: Record): string[] { + const tags = data.tags + if (!Array.isArray(tags)) { + return [] + } + + return tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) +} + +export function normalizeLegacyContext(file: LegacyContextFile): NormalizedContext { + const parsed = parseFrontmatter>(file.content) + const source = typeof parsed.data.source === "string" ? parsed.data.source : file.source + const topic = getFrontmatterTopic(parsed.data) ?? getTopicFromFilename(file.filename) + const content = parsed.hadFrontmatter && !parsed.parseError ? parsed.body : file.content + const tags = [...new Set([...getFrontmatterTags(parsed.data), file.source])] + + return { + topic, + content, + tags, + source, + normalizedAt: Date.now(), + } +} diff --git a/src/features/compat-shims/hooks-config-adapter.test.ts b/src/features/compat-shims/hooks-config-adapter.test.ts new file mode 100644 index 0000000000..12b853dc36 --- /dev/null +++ b/src/features/compat-shims/hooks-config-adapter.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test" +import { adaptLegacyHooksConfig } from "./hooks-config-adapter" + +describe("compat-shims/hooks-config-adapter", () => { + //#given legacy hooks JSON in object format + //#when adaptLegacyHooksConfig is called + //#then it maps known legacy hook names into current names + it("adapts known legacy hook names", () => { + const raw = JSON.stringify({ + disabled_hooks: ["sisyphus-orchestrator", "anthropic-auto-compact"], + }) + + const result = adaptLegacyHooksConfig(raw) + + expect(result.disabledHooks).toEqual([ + "atlas", + "anthropic-context-window-limit-recovery", + ]) + expect(result.warnings).toHaveLength(0) + }) + + //#given legacy hooks JSON with unknown hook names + //#when adaptLegacyHooksConfig is called + //#then it returns warnings without throwing + it("reports unknown hooks as warnings", () => { + const raw = JSON.stringify({ + disabled_hooks: ["atlas", "unknown-hook-name"], + }) + + const result = adaptLegacyHooksConfig(raw) + + expect(result.disabledHooks).toEqual(["atlas"]) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("unknown-hook-name") + }) + + //#given malformed JSON input + //#when adaptLegacyHooksConfig is called + //#then it returns an empty result with warning + it("handles malformed JSON safely", () => { + const result = adaptLegacyHooksConfig("not-json") + + expect(result.disabledHooks).toEqual([]) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("Invalid hooks.json") + }) + + //#given valid JSON that parses to null + //#when adaptLegacyHooksConfig is called + //#then it returns an empty result with warning instead of crashing + it("handles JSON null safely", () => { + const result = adaptLegacyHooksConfig("null") + + expect(result.disabledHooks).toEqual([]) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("expected an object") + }) + + //#given valid JSON that parses to an array + //#when adaptLegacyHooksConfig is called + //#then it returns an empty result with warning + it("handles JSON array safely", () => { + const result = adaptLegacyHooksConfig('["atlas"]') + + expect(result.disabledHooks).toEqual([]) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("expected an object") + }) +}) diff --git a/src/features/compat-shims/hooks-config-adapter.ts b/src/features/compat-shims/hooks-config-adapter.ts new file mode 100644 index 0000000000..928cdded0b --- /dev/null +++ b/src/features/compat-shims/hooks-config-adapter.ts @@ -0,0 +1,76 @@ +import { HookNameSchema } from "../../config/schema/hooks" +import { HOOK_NAME_MAP } from "../../shared/migration/hook-names" + +type LegacyHooksJson = { + disabled_hooks?: unknown + hooks?: unknown +} + +export interface HooksConfigAdaptation { + disabledHooks: string[] + warnings: string[] +} + +function normalizeRawHooks(parsed: LegacyHooksJson): string[] { + if (Array.isArray(parsed.disabled_hooks)) { + return parsed.disabled_hooks.filter((item): item is string => typeof item === "string") + } + + if (Array.isArray(parsed.hooks)) { + return parsed.hooks.filter((item): item is string => typeof item === "string") + } + + return [] +} + +function mapLegacyName(name: string): string | null { + const mapped = HOOK_NAME_MAP[name] + if (mapped === null) { + return null + } + + return mapped ?? name +} + +export function adaptLegacyHooksConfig(raw: string): HooksConfigAdaptation { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return { + disabledHooks: [], + warnings: ["Invalid hooks.json: failed to parse JSON"], + } + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { + disabledHooks: [], + warnings: ["Invalid hooks.json: expected an object"], + } + } + + const warnings: string[] = [] + const normalized = new Set() + + for (const hook of normalizeRawHooks(parsed as LegacyHooksJson)) { + const mapped = mapLegacyName(hook) + + if (mapped === null) { + warnings.push(`Removed legacy hook ignored: ${hook}`) + continue + } + + if (!HookNameSchema.safeParse(mapped).success) { + warnings.push(`Unknown hook ignored: ${hook}`) + continue + } + + normalized.add(mapped) + } + + return { + disabledHooks: [...normalized], + warnings, + } +} diff --git a/src/features/compat-shims/index.ts b/src/features/compat-shims/index.ts new file mode 100644 index 0000000000..f41e7d7de1 --- /dev/null +++ b/src/features/compat-shims/index.ts @@ -0,0 +1,6 @@ +export { normalizeLegacyContext } from "./context-normalizer" +export { adaptLegacyHooksConfig } from "./hooks-config-adapter" +export type { + LegacyContextFile, + NormalizedContext, +} from "./types" diff --git a/src/features/compat-shims/types.ts b/src/features/compat-shims/types.ts new file mode 100644 index 0000000000..6b5df01040 --- /dev/null +++ b/src/features/compat-shims/types.ts @@ -0,0 +1,13 @@ +export interface LegacyContextFile { + filename: string + content: string + source: "opencode-context" | "sisyphus-plan" | "hooks-config" +} + +export interface NormalizedContext { + topic: string + content: string + tags: string[] + source: string + normalizedAt: number +} diff --git a/src/features/mcb-integration/availability.test.ts b/src/features/mcb-integration/availability.test.ts new file mode 100644 index 0000000000..da405a32d0 --- /dev/null +++ b/src/features/mcb-integration/availability.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach } from "bun:test" +import { + getMcbAvailability, + lockMcbAvailability, + markMcbAvailable, + markMcbUnavailable, + resetMcbAvailability, +} from "./availability" + +describe("mcb-integration/availability", () => { + beforeEach(() => { + resetMcbAvailability() + }) + + //#given a fresh state + //#when getMcbAvailability is called + //#then it returns default availability with session disabled + it("returns default availability status", () => { + const status = getMcbAvailability() + expect(status.available).toBe(true) + expect(status.tools.search).toBe(true) + expect(status.tools.memory).toBe(true) + expect(status.tools.index).toBe(true) + expect(status.tools.validate).toBe(true) + expect(status.tools.vcs).toBe(true) + expect(status.tools.session).toBe(false) + expect(status.checkedAt).toBeGreaterThan(0) + }) + + //#given a cached status + //#when getMcbAvailability is called again within TTL + //#then it returns the cached result + it("returns cached result within TTL", () => { + const first = getMcbAvailability() + const second = getMcbAvailability() + expect(first.checkedAt).toBe(second.checkedAt) + }) + + //#given an available MCB + //#when markMcbUnavailable is called with a specific tool + //#then that tool is marked unavailable + it("marks specific tool unavailable", () => { + getMcbAvailability() + markMcbUnavailable("memory") + const status = getMcbAvailability() + expect(status.tools.memory).toBe(false) + expect(status.tools.search).toBe(true) + }) + + //#given an available MCB + //#when markMcbUnavailable is called without args + //#then the entire MCB is marked unavailable + it("marks entire MCB unavailable", () => { + getMcbAvailability() + markMcbUnavailable() + const status = getMcbAvailability() + expect(status.available).toBe(false) + }) + + //#given a modified availability + //#when resetMcbAvailability is called + //#then the next call returns fresh defaults + it("resets availability to defaults", () => { + getMcbAvailability() + markMcbUnavailable("search") + resetMcbAvailability() + const status = getMcbAvailability() + expect(status.tools.search).toBe(true) + }) + + //#given a globally unavailable MCB + //#when markMcbAvailable is called without args + //#then global availability is restored + it("restores global availability", () => { + getMcbAvailability() + markMcbUnavailable() + markMcbAvailable() + const status = getMcbAvailability() + expect(status.available).toBe(true) + }) + + //#given an unavailable tool + //#when markMcbAvailable is called with the tool name + //#then only that tool is restored + it("restores specific tool availability", () => { + getMcbAvailability() + markMcbUnavailable("memory") + markMcbAvailable("memory") + const status = getMcbAvailability() + expect(status.tools.memory).toBe(true) + expect(status.tools.search).toBe(true) + }) + + //#given a locked availability state + //#when markMcbAvailable is called + //#then runtime recovery still updates availability + it("restores availability even when config is locked", () => { + getMcbAvailability() + lockMcbAvailability() + markMcbUnavailable("memory") + markMcbAvailable("memory") + const status = getMcbAvailability() + expect(status.tools.memory).toBe(true) + }) +}) diff --git a/src/features/mcb-integration/availability.ts b/src/features/mcb-integration/availability.ts new file mode 100644 index 0000000000..2b967cbe65 --- /dev/null +++ b/src/features/mcb-integration/availability.ts @@ -0,0 +1,59 @@ +import type { McbAvailabilityStatus, McbToolAvailability } from "./types" + +let cachedStatus: McbAvailabilityStatus | null = null +let configLocked = false +const CACHE_TTL_MS = 60_000 + +export function lockMcbAvailability(): void { + configLocked = true +} + +export function getMcbAvailability(): McbAvailabilityStatus { + if (cachedStatus && (configLocked || Date.now() - cachedStatus.checkedAt < CACHE_TTL_MS)) { + return cachedStatus + } + + cachedStatus = { + available: true, + checkedAt: Date.now(), + tools: { + search: true, + memory: true, + index: true, + validate: true, + vcs: true, + session: false, + }, + } + + return cachedStatus +} + +export function markMcbUnavailable(tool?: keyof McbToolAvailability): void { + if (!cachedStatus) { + getMcbAvailability() + } + + if (tool && cachedStatus) { + cachedStatus.tools[tool] = false + } else if (cachedStatus) { + cachedStatus.available = false + } +} + +export function markMcbAvailable(tool?: keyof McbToolAvailability): void { + if (!cachedStatus) { + getMcbAvailability() + } + + if (tool && cachedStatus) { + cachedStatus.tools[tool] = true + } else if (cachedStatus) { + cachedStatus.available = true + } +} + +export function resetMcbAvailability(): void { + cachedStatus = null + configLocked = false +} diff --git a/src/features/mcb-integration/config-gate.test.ts b/src/features/mcb-integration/config-gate.test.ts new file mode 100644 index 0000000000..e2cacf728f --- /dev/null +++ b/src/features/mcb-integration/config-gate.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { initializeMcbFromConfig } from "./config-gate" +import { getMcbAvailability, resetMcbAvailability } from "./availability" +import type { McbConfig } from "../../config/schema/mcb" + +describe("mcb-integration/config-gate", () => { + beforeEach(() => { + resetMcbAvailability() + }) + + afterEach(() => { + resetMcbAvailability() + }) + + //#given no mcb config (undefined) + //#when initializeMcbFromConfig is called + //#then mcb is completely unavailable and locked + it("disables MCB when config is missing", () => { + initializeMcbFromConfig(undefined) + + const status = getMcbAvailability() + expect(status.available).toBe(false) + + // Verify it's locked (subsequent calls don't reset it) + // We can't directly check 'locked' state, but we can check if it remains unavailable + // even if we try to access it again + const status2 = getMcbAvailability() + expect(status2.available).toBe(false) + }) + + //#given mcb config with enabled: false + //#when initializeMcbFromConfig is called + //#then mcb is completely unavailable and locked + it("disables MCB when enabled is false", () => { + const config: McbConfig = { + enabled: false, + } + initializeMcbFromConfig(config) + + const status = getMcbAvailability() + expect(status.available).toBe(false) + }) + + //#given mcb config with enabled: true + //#when initializeMcbFromConfig is called + //#then mcb is available with all tools enabled by default + it("enables MCB when enabled is true", () => { + const config: McbConfig = { + enabled: true, + url: "http://localhost:3000", + } + initializeMcbFromConfig(config) + + const status = getMcbAvailability() + expect(status.available).toBe(true) + expect(status.tools.search).toBe(true) + expect(status.tools.memory).toBe(true) + }) + + //#given mcb config with enabled: true and specific tools disabled + //#when initializeMcbFromConfig is called + //#then mcb is available but specific tools are disabled + it("respects per-tool configuration", () => { + const config: McbConfig = { + enabled: true, + tools: { + memory: false, + vcs: false, + search: true, // explicit true + // index implicit true + }, + } + initializeMcbFromConfig(config) + + const status = getMcbAvailability() + expect(status.available).toBe(true) + expect(status.tools.memory).toBe(false) + expect(status.tools.vcs).toBe(false) + expect(status.tools.search).toBe(true) + expect(status.tools.index).toBe(true) // default + }) + + //#given mcb is initialized and locked + //#when time passes (simulated) + //#then the configuration is NOT overwritten by cache expiration logic + it("locks configuration against cache expiration", () => { + // 1. Initialize as disabled + initializeMcbFromConfig({ enabled: false }) + + // 2. Verify disabled + expect(getMcbAvailability().available).toBe(false) + + // 3. Even if we manually reset the internal cache (simulating expiry logic inside availability.ts), + // the lock should prevent re-enabling if we were able to modify availability.ts to expose it. + // However, since we can't easily mock the internal state of availability.ts without + // more complex mocking, we rely on the contract that initializeMcbFromConfig calls lockMcbAvailability. + + // Instead, let's verify that calling getMcbAvailability multiple times returns the same result + for (let i = 0; i < 5; i++) { + expect(getMcbAvailability().available).toBe(false) + } + }) +}) diff --git a/src/features/mcb-integration/config-gate.ts b/src/features/mcb-integration/config-gate.ts new file mode 100644 index 0000000000..bf0e78efcd --- /dev/null +++ b/src/features/mcb-integration/config-gate.ts @@ -0,0 +1,24 @@ +import type { McbConfig } from "../../config/schema/mcb" +import type { McbToolAvailability } from "./types" +import { lockMcbAvailability, markMcbUnavailable, resetMcbAvailability } from "./availability" + +export function initializeMcbFromConfig(mcbConfig?: McbConfig): void { + resetMcbAvailability() + + if (!mcbConfig?.enabled) { + markMcbUnavailable() + lockMcbAvailability() + return + } + + if (mcbConfig.tools) { + const toolKeys: (keyof McbToolAvailability)[] = ["search", "memory", "index", "validate", "vcs", "session"] + for (const key of toolKeys) { + if (mcbConfig.tools[key] === false) { + markMcbUnavailable(key) + } + } + } + + lockMcbAvailability() +} diff --git a/src/features/mcb-integration/degradation-warnings.test.ts b/src/features/mcb-integration/degradation-warnings.test.ts new file mode 100644 index 0000000000..54f8fea758 --- /dev/null +++ b/src/features/mcb-integration/degradation-warnings.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" +import * as logger from "../../shared/logger" +import { emitMcbDegradationWarning, resetWarningState } from "./degradation-warnings" + +describe("mcb-integration/degradation-warnings", () => { + let warnSpy: ReturnType + let logSpy: ReturnType + + beforeEach(() => { + resetWarningState() + warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + logSpy = spyOn(logger, "log").mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + logSpy.mockRestore() + }) + + //#given a tool degradation event + //#when a warning is emitted + //#then it logs to console and file logger + it("emits warning and logs it", () => { + emitMcbDegradationWarning("memory") + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledTimes(1) + const message = warnSpy.mock.calls[0]?.[0] + expect(String(message)).toContain("memory") + expect(String(message)).toContain("Affected capabilities") + }) + + //#given repeated degradation events for the same tool + //#when warning emission is requested twice + //#then only the first warning is emitted + it("deduplicates warnings by tool", () => { + emitMcbDegradationWarning("search") + emitMcbDegradationWarning("search") + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledTimes(1) + }) + + //#given warning state was reset + //#when the same tool warning is emitted again + //#then warning is emitted again + it("allows re-emission after reset", () => { + emitMcbDegradationWarning("validate") + resetWarningState() + emitMcbDegradationWarning("validate") + expect(warnSpy).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/mcb-integration/degradation-warnings.ts b/src/features/mcb-integration/degradation-warnings.ts new file mode 100644 index 0000000000..a50fa689b6 --- /dev/null +++ b/src/features/mcb-integration/degradation-warnings.ts @@ -0,0 +1,29 @@ +import * as logger from "../../shared/logger" +import type { McbToolAvailability } from "./types" + +const emittedWarnings = new Set() + +const capabilityByTool: Record = { + memory: "learning storage, artifact ingestion, observation persistence", + search: "semantic code search, memory search, context search", + index: "codebase indexing, embedding generation", + validate: "code quality validation, rule enforcement", + vcs: "git-aware context, branch comparison, impact analysis", + session: "session lifecycle tracking, activity logging", +} + +export function emitMcbDegradationWarning(tool: keyof McbToolAvailability): void { + if (emittedWarnings.has(tool)) { + return + } + + const capabilities = capabilityByTool[tool] + const message = `[mcb] MCB tool '${tool}' is unavailable. Affected capabilities: ${capabilities}. Operations will be queued for sync when MCB recovers.` + emittedWarnings.add(tool) + console.warn(message) + logger.log(message) +} + +export function resetWarningState(): void { + emittedWarnings.clear() +} diff --git a/src/features/mcb-integration/e2e-roundtrip-index.test.ts b/src/features/mcb-integration/e2e-roundtrip-index.test.ts new file mode 100644 index 0000000000..92dc06a09d --- /dev/null +++ b/src/features/mcb-integration/e2e-roundtrip-index.test.ts @@ -0,0 +1,116 @@ +import { Database } from "bun:sqlite" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join, resolve } from "path" +import { + callMcbTool, + createDefaultArgs, + createMcbTestClient, + parseMcbToolResponse, + type McbTestClient, +} from "./mcb-client-helper" +import { waitForIndexReady } from "./mcb-roundtrip-helpers" + +const mcbAvailable = Bun.which("mcb") !== null +const configPath = resolve(import.meta.dir, "test-mcb.toml") +const dbPath = `/tmp/mcb-e2e-index-${Date.now()}.db` + +describe.skipIf(!mcbAvailable)("mcb index roundtrip with DB verification", () => { + let testClient: McbTestClient + let tempDir = "" + + beforeAll(async () => { + testClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: dbPath, + }) + tempDir = mkdtempSync(join(tmpdir(), "mcb-e2e-index-")) + writeFileSync( + join(tempDir, "calculator.ts"), + "export function calculateTotalRoundtripMarker(items: number[]): number { return items.reduce((sum, item) => sum + item, 0) }\n", + ) + }, 60_000) + + afterAll(async () => { + await testClient?.close() + if (tempDir) rmSync(tempDir, { recursive: true, force: true }) + for (const ext of ["", "-wal", "-shm"]) rmSync(dbPath + ext, { force: true }) + }) + + //#given a temp directory with a known .ts file indexed via MCP + //#when the index operation completes and we query SQLite directly + //#then collections and file_hashes tables should contain persisted rows + test("index start persists to collections and file_hashes tables", async () => { + const collection = `e2e_index_${Date.now()}` + const startResult = await callMcbTool(testClient.client, "index", { + ...createDefaultArgs("index"), + action: "start", + path: tempDir, + collection, + extensions: ["ts"], + }) + expect(startResult.isError).not.toBe(true) + + await waitForIndexReady(testClient.client, collection, { + maxWaitMs: 30_000, + intervalMs: 500, + minIdleReadyMs: 1_000, + }) + + expect(existsSync(dbPath)).toBe(true) + const db = new Database(dbPath, { readonly: true }) + try { + const hasCollections = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='collections'").get() + expect(hasCollections).toBeTruthy() + const collections = db.query("SELECT * FROM collections WHERE name = ?").all(collection) + expect(collections.length).toBeGreaterThan(0) + + const hasFileHashes = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'").get() + expect(hasFileHashes).toBeTruthy() + const hashes = db.query("SELECT * FROM file_hashes WHERE collection = ?").all(collection) as Record[] + expect(hashes.length).toBeGreaterThan(0) + const paths = hashes.map((h) => String(h.file_path ?? "")) + expect(paths.some((p) => p.includes("calculator.ts"))).toBe(true) + } finally { + db.close() + } + }, 45_000) + + //#given an indexed collection and a code search query + //#when search returns results but DB has 0 file_hashes rows + //#then this documents MCB doing filesystem search instead of vector search + test("search code cross-references with DB state", async () => { + const collection = `e2e_search_${Date.now()}` + await callMcbTool(testClient.client, "index", { + ...createDefaultArgs("index"), + action: "start", + path: tempDir, + collection, + extensions: ["ts"], + }) + await waitForIndexReady(testClient.client, collection, { + maxWaitMs: 30_000, + intervalMs: 500, + minIdleReadyMs: 1_000, + }) + + const searchResult = await callMcbTool(testClient.client, "search", { + ...createDefaultArgs("search"), + resource: "code", + query: "calculateTotalRoundtripMarker", + collection, + }) + const payload = JSON.stringify(parseMcbToolResponse(searchResult)) + const foundViaSearch = payload.includes("calculateTotalRoundtripMarker") + + const db = new Database(dbPath, { readonly: true }) + try { + const hasFileHashes = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'").get() + expect(hasFileHashes).toBeTruthy() + const dbRowCount = (db.query("SELECT COUNT(*) as cnt FROM file_hashes WHERE collection = ?").get(collection) as Record)?.cnt ?? 0 + expect(Number(dbRowCount)).toBeGreaterThan(0) + } finally { + db.close() + } + }, 45_000) +}) diff --git a/src/features/mcb-integration/e2e-roundtrip-session.test.ts b/src/features/mcb-integration/e2e-roundtrip-session.test.ts new file mode 100644 index 0000000000..be508e34eb --- /dev/null +++ b/src/features/mcb-integration/e2e-roundtrip-session.test.ts @@ -0,0 +1,147 @@ +import { Database } from "bun:sqlite" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { existsSync, rmSync } from "fs" +import { resolve } from "path" +import { + callMcbTool, + createDefaultArgs, + createMcbTestClient, + parseMcbToolResponse, + type McbTestClient, +} from "./mcb-client-helper" + +const mcbAvailable = Bun.which("mcb") !== null +const configPath = resolve(import.meta.dir, "test-mcb.toml") +const dbPath = `/tmp/mcb-e2e-session-${Date.now()}.db` + +describe.skipIf(!mcbAvailable)("mcb session roundtrip with DB verification", () => { + let testClient: McbTestClient + + beforeAll(async () => { + testClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: dbPath, + }) + }, 60_000) + + afterAll(async () => { + await testClient?.close() + for (const ext of ["", "-wal", "-shm"]) rmSync(dbPath + ext, { force: true }) + }) + + //#given a session create call with all required fields per MCB create.rs + //#when the MCP operation completes and we query the SQLite DB directly + //#then agent_sessions table should contain the persisted row + test("session create persists to agent_sessions table", async () => { + const result = await callMcbTool(testClient.client, "session", { + ...createDefaultArgs("session"), + action: "create", + agent_type: "sisyphus", + data: { name: "e2e-session-test", session_summary_id: `e2e-summary-${Date.now()}`, model: "test-model" }, + }) + + expect(result.isError).toBe(false) + + const parsed = parseMcbToolResponse(result) + const sessionId = extractSessionId(parsed) + expect(sessionId).not.toBeNull() + + expect(existsSync(dbPath)).toBe(true) + const db = new Database(dbPath, { readonly: true }) + try { + const hasTable = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_sessions'").get() + expect(hasTable).toBeTruthy() + + const rows = db.query("SELECT * FROM agent_sessions").all() as Record[] + expect(rows.length).toBeGreaterThan(0) + + if (sessionId && rows.length > 0) { + const match = rows.find((r) => r.id === sessionId) + expect(match).toBeTruthy() + expect(match?.agent_type).toBe("sisyphus") + expect(match?.status).toBe("active") + } + } finally { + db.close() + } + }, 20_000) + + //#given a session create then get with the returned session_id + //#when we query the SQLite DB for the specific row + //#then the DB row fields should match the MCP get response + test("session get returns data matching DB state", async () => { + const createResult = await callMcbTool(testClient.client, "session", { + ...createDefaultArgs("session"), + action: "create", + agent_type: "explore", + data: { name: "e2e-get-test", session_summary_id: `e2e-summary-${Date.now()}`, model: "test-model" }, + }) + const sessionId = extractSessionId(parseMcbToolResponse(createResult)) + + if (!sessionId) { + expect(sessionId).not.toBeNull() + return + } + + await callMcbTool(testClient.client, "session", { + ...createDefaultArgs("session"), + action: "get", + session_id: sessionId, + }) + + const db = new Database(dbPath, { readonly: true }) + try { + const row = db.query("SELECT * FROM agent_sessions WHERE id = ?").get(sessionId) as Record | null + expect(row).not.toBeNull() + if (row) { + expect(row.agent_type).toBe("explore") + expect(row.status).toBe("active") + } + } finally { + db.close() + } + }, 20_000) + + //#given a memory store observation call with required project_id + //#when the MCP operation completes and DB is queried + //#then observations table has a row matching our content, type, and project_id + test("memory store observation persists to observations table", async () => { + const storeResult = await callMcbTool(testClient.client, "memory", { + ...createDefaultArgs("memory"), + action: "store", + resource: "observation", + project_id: "test-project", + data: { content: "e2e-memory-test", observation_type: "code", project_id: "test-project" }, + }) + const storePayload = parseMcbToolResponse(storeResult) + const storeText = typeof storePayload === "string" ? storePayload : JSON.stringify(storePayload) + expect(storeText).toContain("observation_id") + + const db = new Database(dbPath, { readonly: true }) + try { + const hasTable = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='observations'").get() + expect(hasTable).toBeTruthy() + + const rows = db.query("SELECT * FROM observations WHERE project_id = ? AND content = ?").all( + "test-project", + "e2e-memory-test", + ) as Record[] + expect(rows.length).toBeGreaterThan(0) + expect(rows[0]!.observation_type).toBe("code") + expect(rows[0]!.project_id).toBe("test-project") + expect(rows[0]!.content).toBe("e2e-memory-test") + } finally { + db.close() + } + }, 20_000) +}) + +function extractSessionId(payload: unknown): string | null { + if (typeof payload === "object" && payload !== null) { + const record = payload as Record + if (typeof record.session_id === "string" && record.session_id.length > 0) return record.session_id + if (typeof record.id === "string" && record.id.length > 0) return record.id + } + const rawText = typeof payload === "string" ? payload : JSON.stringify(payload) + const matched = rawText.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i) + return matched?.[0] ?? null +} diff --git a/src/features/mcb-integration/e2e-tier1.test.ts b/src/features/mcb-integration/e2e-tier1.test.ts new file mode 100644 index 0000000000..f45168c1f0 --- /dev/null +++ b/src/features/mcb-integration/e2e-tier1.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { resolve } from "path" +import { MCB_TOOL_NAMES, type McbToolName } from "./types" +import { createMcbTestClient, type McbTestClient } from "./mcb-client-helper" + +const mcbAvailable = Bun.which("mcb") !== null +const configPath = resolve(import.meta.dir, "test-mcb.toml") +const dbPath = `/tmp/mcb-e2e-tier1-${Date.now()}.db` + +describe.skipIf(!mcbAvailable)("mcb-integration: e2e tier1 connection lifecycle", () => { + let testClient: McbTestClient + + beforeAll(async () => { + testClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: dbPath, + }) + }, 60_000) + + afterAll(async () => { + await testClient?.close() + }) + + //#given a connected real mcb client + //#when tools are listed + //#then all expected tool names are present + test("lists all 9 expected mcb tools", async () => { + const result = await testClient.client.listTools() + const names = new Set(result.tools.map((tool) => tool.name as McbToolName)) + expect(result.tools.length).toBe(9) + for (const expectedName of MCB_TOOL_NAMES) { + expect(names.has(expectedName)).toBe(true) + } + }, 10_000) + + //#given a connected real mcb client + //#when tool schemas are inspected + //#then each tool exposes an object input schema + test("exposes input schema for all tools", async () => { + const result = await testClient.client.listTools() + for (const tool of result.tools) { + expect(tool.inputSchema).toBeDefined() + expect(typeof tool.inputSchema).toBe("object") + const schema = tool.inputSchema as { type?: unknown } + expect(schema.type).toBe("object") + } + }, 10_000) + + //#given a connected real mcb client + //#when the client is closed twice + //#then close remains safe and idempotent + test("closes safely multiple times", async () => { + const localClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: `/tmp/mcb-e2e-tier1-local-${Date.now()}.db`, + }) + await expect(localClient.close()).resolves.toBeUndefined() + await expect(localClient.close()).resolves.toBeUndefined() + }, 60_000) + + //#given an invalid mcb command + //#when connection is attempted + //#then the client connection fails gracefully + test("fails to connect with invalid command", async () => { + const transport = new StdioClientTransport({ command: "mcb-not-found", args: ["serve"], stderr: "pipe" }) + const client = new Client({ name: "mcb-e2e-test", version: "0.1.0" }, { capabilities: {} }) + await expect(client.connect(transport)).rejects.toBeDefined() + await transport.close().catch(() => undefined) + }, 10_000) +}) diff --git a/src/features/mcb-integration/e2e-tier2.test.ts b/src/features/mcb-integration/e2e-tier2.test.ts new file mode 100644 index 0000000000..559c7e617c --- /dev/null +++ b/src/features/mcb-integration/e2e-tier2.test.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { resolve } from "path" +import { callMcbTool, createDefaultArgs, createMcbTestClient, parseMcbToolResponse, type McbTestClient } from "./mcb-client-helper" + +const mcbAvailable = Bun.which("mcb") !== null +const configPath = resolve(import.meta.dir, "test-mcb.toml") +const dbPath = `/tmp/mcb-e2e-tier2-${Date.now()}.db` + +describe.skipIf(!mcbAvailable)("mcb-integration: e2e tier2 core tool invocation", () => { + let testClient: McbTestClient + + beforeAll(async () => { + testClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: dbPath, + }) + }, 60_000) + + afterAll(async () => { + await testClient?.close() + }) + + //#given a memory store call without execution provenance + //#when store is requested for observation resource + //#then mcb rejects the call with invalid_params provenance error + test("memory store without execution provenance returns invalid_params", async () => { + const args = { + ...createDefaultArgs("memory"), + action: "store", + resource: "observation", + data: { content: "e2e test observation", source: "oh-my-opencode", observation_type: "code" }, + _meta: {}, + } + const result = await callMcbTool(testClient.client, "memory", args) + expect(Array.isArray(result.content)).toBe(true) + expect(result.content.length).toBeGreaterThan(0) + expect(result.content[0]?.type).toBe("text") + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toContain("Missing execution provenance") + }, 10_000) + + //#given memory list call with empty query + //#when memory list is invoked + //#then a response payload is returned in text content + test("memory list with empty query returns textual payload", async () => { + const result = await callMcbTool(testClient.client, "memory", createDefaultArgs("memory")) + expect(result.content.length).toBeGreaterThan(0) + expect(typeof result.content[0]?.text).toBe("string") + }, 10_000) + + //#given memory list call with non-empty query + //#when memory list executes + //#then result is parseable as json or plain text payload + test("memory list with query returns parseable payload", async () => { + const args = { ...createDefaultArgs("memory"), action: "list", query: "test" } + const result = await callMcbTool(testClient.client, "memory", args) + const parsed = parseMcbToolResponse(result) + if (typeof parsed === "object" && parsed !== null && "count" in parsed) { + expect(typeof (parsed as { count: unknown }).count).toBe("number") + return + } + expect(typeof (parsed as { text?: unknown }).text).toBe("string") + }, 10_000) + + //#given search memory invocation + //#when semantic search runs on empty dataset + //#then response is parseable and includes count or text payload + test("search memory returns parseable response", async () => { + const args = { ...createDefaultArgs("search"), resource: "memory", query: "unrelated-query" } + const result = await callMcbTool(testClient.client, "search", args) + const parsed = parseMcbToolResponse(result) + if (typeof parsed === "object" && parsed !== null && "count" in parsed) { + expect(typeof (parsed as { count: unknown }).count).toBe("number") + return + } + expect(typeof (parsed as { text?: unknown }).text).toBe("string") + }, 10_000) + + //#given search code invocation + //#when semantic code search runs without index data + //#then response shape remains valid + test("search code returns parseable response", async () => { + const args = { ...createDefaultArgs("search"), resource: "code", query: "function" } + const result = await callMcbTool(testClient.client, "search", args) + const parsed = parseMcbToolResponse(result) + if (typeof parsed === "object" && parsed !== null && "count" in parsed) { + expect(typeof (parsed as { count: unknown }).count).toBe("number") + return + } + expect(typeof (parsed as { text?: unknown }).text).toBe("string") + }, 10_000) + + //#given validate tool invocation with list_rules + //#when called with current mcb 0.2.1-dev configuration + //#then mcb returns a successful rules payload (currently empty in local config) + test("validate list_rules returns successful payload", async () => { + const result = await callMcbTool(testClient.client, "validate", createDefaultArgs("validate")) + expect(result.content.length).toBeGreaterThan(0) + expect(result.content[0]?.type).toBe("text") + expect(result.isError).toBe(false) + const parsed = parseMcbToolResponse(result) as Record + expect(typeof parsed.count).toBe("number") + expect(Array.isArray(parsed.rules)).toBe(true) + }, 10_000) + + //#given index tool invocation + //#when status action is requested + //#then mcb returns a textual status payload + test("index status returns textual payload", async () => { + const result = await callMcbTool(testClient.client, "index", createDefaultArgs("index")) + expect(result.content.length).toBeGreaterThan(0) + expect(typeof result.content[0]?.text).toBe("string") + }, 10_000) +}) diff --git a/src/features/mcb-integration/e2e-tier3.test.ts b/src/features/mcb-integration/e2e-tier3.test.ts new file mode 100644 index 0000000000..1bf70d3c83 --- /dev/null +++ b/src/features/mcb-integration/e2e-tier3.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join, resolve } from "path" +import { callMcbTool, createDefaultArgs, createMcbTestClient, parseMcbToolResponse, type McbTestClient } from "./mcb-client-helper" + +const mcbAvailable = Bun.which("mcb") !== null +const configPath = resolve(import.meta.dir, "test-mcb.toml") +const dbPath = `/tmp/mcb-e2e-tier3-${Date.now()}.db` + +describe.skipIf(!mcbAvailable)("mcb-integration: e2e tier3 extended operations", () => { + let testClient: McbTestClient + let tempDir = "" + + beforeAll(async () => { + testClient = await createMcbTestClient(60_000, configPath, { + MCP__AUTH__USER_DB_PATH: dbPath, + }) + tempDir = mkdtempSync(join(tmpdir(), "mcb-e2e-")) + writeFileSync(join(tempDir, "sample.ts"), "export const value = 1\n", "utf-8") + }, 60_000) + + afterAll(async () => { + await testClient?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + //#given vcs tool defaults + //#when list_repositories action runs + //#then the response payload is returned with valid shape + test("vcs list_repositories returns parseable payload", async () => { + const result = await callMcbTool(testClient.client, "vcs", createDefaultArgs("vcs")) + expect(result.content.length).toBeGreaterThan(0) + expect(typeof result.content[0]?.text).toBe("string") + }, 10_000) + + //#given session tool defaults + //#when list action runs + //#then the response is parseable or plain text + test("session list returns parseable payload", async () => { + const result = await callMcbTool(testClient.client, "session", createDefaultArgs("session")) + const parsed = parseMcbToolResponse(result) + if (typeof parsed === "object" && parsed !== null) { + expect(parsed).toBeDefined() + return + } + expect(typeof parsed).toBe("string") + }, 10_000) + + //#given validate tool defaults + //#when analyze action targets a valid file + //#then mcb returns a payload in expected text envelope + test("validate analyze returns textual payload for existing file", async () => { + const args = { + ...createDefaultArgs("validate"), + action: "analyze", + scope: "file", + path: join(tempDir, "sample.ts"), + } + const result = await callMcbTool(testClient.client, "validate", args) + expect(result.content.length).toBeGreaterThan(0) + expect(result.content[0]?.type).toBe("text") + }, 10_000) + + //#given index tool defaults + //#when start action runs against temporary code directory + //#then mcb returns a valid response envelope + test("index start on temp directory returns textual payload", async () => { + const args = { + ...createDefaultArgs("index"), + action: "start", + path: tempDir, + collection: `e2e-${Date.now()}`, + extensions: ["ts"], + } + const result = await callMcbTool(testClient.client, "index", args) + expect(result.content.length).toBeGreaterThan(0) + expect(typeof result.content[0]?.text).toBe("string") + }, 10_000) +}) diff --git a/src/features/mcb-integration/graceful-wrapper.test.ts b/src/features/mcb-integration/graceful-wrapper.test.ts new file mode 100644 index 0000000000..7f86a15c8c --- /dev/null +++ b/src/features/mcb-integration/graceful-wrapper.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" +import * as warnings from "./degradation-warnings" +import * as syncQueue from "./sync-queue" +import { withMcbFallback } from "./graceful-wrapper" +import { markMcbUnavailable, resetMcbAvailability } from "./availability" + +describe("mcb-integration/graceful-wrapper", () => { + let enqueueSpy: ReturnType + let warningSpy: ReturnType + + beforeEach(() => { + resetMcbAvailability() + enqueueSpy = spyOn(syncQueue, "enqueueOperation").mockResolvedValue(undefined) + warningSpy = spyOn(warnings, "emitMcbDegradationWarning").mockImplementation(() => {}) + }) + + afterEach(() => { + enqueueSpy.mockRestore() + warningSpy.mockRestore() + }) + + //#given a successful MCB operation + //#when withMcbFallback wraps it + //#then it returns success with data + it("returns success when operation succeeds", async () => { + const result = await withMcbFallback( + async () => ({ items: ["a", "b"] }), + "search", + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.items).toEqual(["a", "b"]) + } + }) + + //#given a failing MCB operation + //#when withMcbFallback wraps it + //#then it returns degraded result and marks tool unavailable + it("returns degraded on operation failure", async () => { + const result = await withMcbFallback( + async () => { throw new Error("connection refused") }, + "search", + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBe("connection refused") + expect(result.degraded).toBe(true) + } + }) + + //#given a failing operation and queue metadata + //#when withMcbFallback handles the failure + //#then it queues the operation for later sync + it("queues failed operations when descriptor and projectDir are provided", async () => { + const result = await withMcbFallback( + async () => { + throw new Error("connection refused") + }, + "memory", + { + tool: "memory", + action: "store", + params: { path: "artifact.md" }, + maxRetries: 3, + source: "test", + }, + "/tmp/project", + ) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.queued).toBe(true) + } + expect(enqueueSpy).toHaveBeenCalledTimes(1) + }) + + //#given MCB unavailability + //#when withMcbFallback short-circuits + //#then warning is emitted for the tool + it("emits degradation warning when tool is unavailable", async () => { + markMcbUnavailable("search") + await withMcbFallback(async () => "data", "search") + expect(warningSpy).toHaveBeenCalledWith("search") + }) + + //#given MCB is globally unavailable + //#when withMcbFallback is called + //#then it short-circuits without calling the operation + it("short-circuits when MCB globally unavailable", async () => { + markMcbUnavailable() + let called = false + const result = await withMcbFallback( + async () => { called = true; return "data" }, + ) + expect(called).toBe(false) + expect(result.success).toBe(false) + }) + + //#given a specific MCB tool is unavailable + //#when withMcbFallback targets that tool + //#then it short-circuits + it("short-circuits when specific tool unavailable", async () => { + markMcbUnavailable("session") + let called = false + const result = await withMcbFallback( + async () => { called = true; return "data" }, + "session", + ) + expect(called).toBe(false) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("session") + } + }) +}) diff --git a/src/features/mcb-integration/graceful-wrapper.ts b/src/features/mcb-integration/graceful-wrapper.ts new file mode 100644 index 0000000000..4229fb5481 --- /dev/null +++ b/src/features/mcb-integration/graceful-wrapper.ts @@ -0,0 +1,65 @@ +import type { McbToolAvailability } from "./types" +import { getMcbAvailability, markMcbUnavailable } from "./availability" +import type { QueuedMcbOperation } from "./sync-queue-types" +import { enqueueOperation } from "./sync-queue" +import { emitMcbDegradationWarning } from "./degradation-warnings" + +export type McbOperationResult = + | { success: true; data: T } + | { success: false; error: string; degraded: true; queued?: boolean } + +export async function withMcbFallback( + operation: () => Promise, + toolName?: keyof McbToolAvailability, + queueDescriptor?: Omit, + projectDir?: string, +): Promise> { + const queueForLaterSync = async (): Promise => { + if (!queueDescriptor || !projectDir) { + return false + } + + await enqueueOperation(projectDir, { + ...queueDescriptor, + id: crypto.randomUUID(), + queuedAt: Date.now(), + retryCount: 0, + }) + + return true + } + + const status = getMcbAvailability() + + if (!status.available) { + if (toolName) { + emitMcbDegradationWarning(toolName) + } + const queued = await queueForLaterSync() + return { success: false, error: "MCB unavailable", degraded: true, queued } + } + + if (toolName && !status.tools[toolName]) { + emitMcbDegradationWarning(toolName) + const queued = await queueForLaterSync() + return { + success: false, + error: `MCB tool ${toolName} unavailable`, + degraded: true, + queued, + } + } + + try { + const data = await operation() + return { success: true, data } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (toolName) { + markMcbUnavailable(toolName) + emitMcbDegradationWarning(toolName) + } + const queued = await queueForLaterSync() + return { success: false, error: message, degraded: true, queued } + } +} diff --git a/src/features/mcb-integration/index.ts b/src/features/mcb-integration/index.ts new file mode 100644 index 0000000000..f3727dbbee --- /dev/null +++ b/src/features/mcb-integration/index.ts @@ -0,0 +1,52 @@ +export { + getMcbAvailability, + markMcbAvailable, + markMcbUnavailable, + resetMcbAvailability, +} from "./availability" +export { withMcbFallback } from "./graceful-wrapper" +export type { McbOperationResult } from "./graceful-wrapper" +export { initializeMcbFromConfig } from "./config-gate" +export { callMcbTool, createDefaultArgs, createMcbTestClient, parseMcbToolResponse } from "./mcb-client-helper" +export type { McbTestClient } from "./mcb-client-helper" +export { discoverValidAgentType, waitForIndexReady } from "./mcb-roundtrip-helpers" +export { + clearQueue, + dequeueOperation, + enqueueOperation, + evictStaleEntries, + getQueueSize, + peekQueue, + saveQueue, +} from "./sync-queue" +export { emitMcbDegradationWarning, resetWarningState } from "./degradation-warnings" +export { attemptRecoverySync } from "./recovery-sync" +export type { RecoverySyncResult, McbOperationExecutor } from "./recovery-sync" +export { handleMcbSessionCreated } from "./session-lifecycle" +export type { QueuedMcbOperation, SyncQueueConfig } from "./sync-queue-types" +export type { + McbCallToolResult, + McbSearchParams, + McbMemoryStoreParams, + McbIndexParams, + McbValidateParams, + McbSearchArgs, + McbMemoryArgs, + McbIndexArgs, + McbValidateArgs, + McbVcsArgs, + McbSessionArgs, + McbAvailabilityStatus, + McbToolAvailability, + McbSearchResource, + McbMemoryAction, + McbMemoryResource, + McbIndexAction, + McbValidateAction, + McbValidateScope, + McbVcsAction, + McbSessionAction, + McbToolName, + McbTextContent, +} from "./types" +export { MCB_TOOL_NAMES } from "./types" diff --git a/src/features/mcb-integration/integration.test.ts b/src/features/mcb-integration/integration.test.ts new file mode 100644 index 0000000000..753a53a668 --- /dev/null +++ b/src/features/mcb-integration/integration.test.ts @@ -0,0 +1,200 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { join } from "node:path" +import { createBuiltinSkills } from "../builtin-skills/skills" +import { McbConfigSchema } from "../../config/schema/mcb" +import { initializeMcbFromConfig } from "./config-gate" +import { getMcbAvailability, resetMcbAvailability } from "./availability" + +const mcbBinaryPath = Bun.which("mcb") +const mcbAvailable = mcbBinaryPath !== null + +describe("mcb-integration: skill registration", () => { + test("oc-mcb skill is registered in builtin skills", () => { + //#given + const skills = createBuiltinSkills() + + //#when + const mcbSkill = skills.find((s) => s.name === "oc-mcb") + + //#then + expect(mcbSkill).toBeDefined() + expect(mcbSkill!.mcpConfig).toHaveProperty("mcb") + expect(mcbSkill!.mcpConfig!.mcb.command).toBe("mcb") + expect(mcbSkill!.mcpConfig!.mcb.args).toEqual(["serve"]) + }) + + test("oc-mcb skill can be disabled via disabledSkills", () => { + //#given + const skills = createBuiltinSkills({ disabledSkills: new Set(["oc-mcb"]) }) + + //#when + const mcbSkill = skills.find((s) => s.name === "oc-mcb") + + //#then + expect(mcbSkill).toBeUndefined() + }) +}) + +describe("mcb-integration: config schema", () => { + test("accepts config with new command/args fields", () => { + //#given + const config = { + enabled: true, + command: "mcb", + args: ["serve"], + data_dir: "/tmp/mcb-data", + } + + //#when + const result = McbConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("accepts config with env field", () => { + //#given + const config = { + enabled: true, + command: "mcb", + args: ["serve"], + env: { MCB_DATA_DIR: "/tmp/mcb-data" }, + } + + //#when + const result = McbConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("preserves backward compatibility with url field", () => { + //#given + const config = { + enabled: true, + url: "http://localhost:3100", + } + + //#when + const result = McbConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("accepts combined url and command fields", () => { + //#given + const config = { + enabled: true, + url: "http://localhost:3100", + command: "mcb", + args: ["serve"], + } + + //#when + const result = McbConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) +}) + +describe("mcb-integration: availability with config", () => { + test("MCB is unavailable when disabled in config", () => { + //#given + resetMcbAvailability() + initializeMcbFromConfig({ enabled: false }) + + //#when + const status = getMcbAvailability() + + //#then + expect(status.available).toBe(false) + }) + + test("MCB is available when enabled in config", () => { + //#given + resetMcbAvailability() + initializeMcbFromConfig({ enabled: true }) + + //#when + const status = getMcbAvailability() + + //#then + expect(status.available).toBe(true) + }) + + test("per-tool disabling works via config", () => { + //#given + resetMcbAvailability() + initializeMcbFromConfig({ enabled: true, tools: { search: false, memory: true } }) + + //#when + const status = getMcbAvailability() + + //#then + expect(status.tools.search).toBe(false) + expect(status.tools.memory).toBe(true) + }) +}) + +describe.skipIf(!mcbAvailable)("mcb-integration: real binary", () => { + const configPath = join(import.meta.dir, "test-mcb.toml") + const dbPath = join(import.meta.dir, `test-integration-${Date.now()}.db`) + + beforeAll(() => { + expect(mcbBinaryPath).not.toBeNull() + }) + + afterAll(async () => { + const { unlink } = await import("node:fs/promises") + await unlink(dbPath).catch(() => {}) + await unlink(`${dbPath}-shm`).catch(() => {}) + await unlink(`${dbPath}-wal`).catch(() => {}) + }) + + test("mcb binary is the expected version", async () => { + //#given + const proc = Bun.spawn(["mcb", "--version"], { stdout: "pipe", stderr: "pipe" }) + + //#when + const output = await new Response(proc.stdout).text() + await proc.exited + + //#then + expect(output.trim()).toMatch(/^mcb \d+\.\d+\.\d+/) + }) + + test("mcb serve --help shows serve command info", async () => { + //#given + const proc = Bun.spawn(["mcb", "serve", "--help"], { stdout: "pipe", stderr: "pipe" }) + + //#when + const output = await new Response(proc.stdout).text() + await proc.exited + + //#then + expect(output).toContain("serve") + }) + + test("mcb serve process starts and accepts stdin with config", async () => { + //#given - MCB 0.2.1+ requires config and DB path to stay alive + const proc = Bun.spawn(["mcb", "serve", "--config", configPath], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, MCP__AUTH__USER_DB_PATH: dbPath }, + }) + + //#when + await new Promise((resolve) => setTimeout(resolve, 2000)) + const exited = proc.exitCode + + proc.stdin.end() + proc.kill() + await proc.exited + + //#then - process should still be running (exitCode null) after 2s + expect(exited).toBeNull() + }, 30_000) +}) diff --git a/src/features/mcb-integration/mcb-client-helper.test.ts b/src/features/mcb-integration/mcb-client-helper.test.ts new file mode 100644 index 0000000000..0b9a8be6ab --- /dev/null +++ b/src/features/mcb-integration/mcb-client-helper.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test" +import { createDefaultArgs, parseMcbToolResponse } from "./mcb-client-helper" + +describe("mcb-integration/mcb-client-helper", () => { + //#given memory tool defaults + //#when createDefaultArgs is called + //#then all required memory fields are present + it("builds required default args for memory", () => { + const args = createDefaultArgs("memory") + expect(args).toHaveProperty("action") + expect(args).toHaveProperty("resource") + expect(args).toHaveProperty("data") + expect(args).toHaveProperty("ids") + expect(args).toHaveProperty("project_id") + expect(args).toHaveProperty("repo_id") + expect(args).toHaveProperty("session_id") + expect(args).toHaveProperty("tags") + expect(args).toHaveProperty("query") + expect(args).toHaveProperty("anchor_id") + expect(args).toHaveProperty("depth_before") + expect(args).toHaveProperty("depth_after") + expect(args).toHaveProperty("window_secs") + expect(args).toHaveProperty("observation_types") + expect(args).toHaveProperty("max_tokens") + expect(args).toHaveProperty("limit") + }) + + //#given search tool defaults + //#when createDefaultArgs is called + //#then all required search fields are present + it("builds required default args for search", () => { + const args = createDefaultArgs("search") + expect(args).toHaveProperty("query") + expect(args).toHaveProperty("resource") + expect(args).toHaveProperty("collection") + expect(args).toHaveProperty("extensions") + expect(args).toHaveProperty("filters") + expect(args).toHaveProperty("limit") + expect(args).toHaveProperty("min_score") + expect(args).toHaveProperty("tags") + expect(args).toHaveProperty("session_id") + expect(args).toHaveProperty("token") + }) + + //#given the entity tool + //#when createDefaultArgs is called + //#then it returns empty defaults (entity not exercised in these tests) + it("returns empty defaults for entity tool", () => { + const args = createDefaultArgs("entity") + expect(args).toEqual({}) + }) + + //#given MCP response text with JSON content + //#when parseMcbToolResponse runs + //#then it returns parsed JSON + it("parses JSON response text", () => { + const parsed = parseMcbToolResponse({ + content: [{ type: "text", text: '{"count":0,"items":[]}' }], + }) + expect(parsed).toEqual({ count: 0, items: [] }) + }) + + //#given MCP response text with plain text + //#when parseMcbToolResponse runs + //#then it returns plain text payload + it("returns plain text when response is not JSON", () => { + const parsed = parseMcbToolResponse({ + content: [{ type: "text", text: "internal error" }], + isError: true, + }) + expect(parsed).toEqual({ text: "internal error", isError: true }) + }) + + //#given MCP response without text items + //#when parseMcbToolResponse runs + //#then it returns empty text payload + it("handles response without text content", () => { + const parsed = parseMcbToolResponse({ content: [{ type: "image", text: "" }] }) + expect(parsed).toEqual({ text: "", isError: false }) + }) +}) diff --git a/src/features/mcb-integration/mcb-client-helper.ts b/src/features/mcb-integration/mcb-client-helper.ts new file mode 100644 index 0000000000..b87696be62 --- /dev/null +++ b/src/features/mcb-integration/mcb-client-helper.ts @@ -0,0 +1,197 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import type { McbCallToolResult, McbToolName } from "./types" +export interface McbTestClient { + client: Client + transport: StdioClientTransport + close: () => Promise +} + +export async function createMcbTestClient( + timeoutMs = 10_000, + configPath?: string, + env?: Record, +): Promise { + const serveArgs = configPath ? ["serve", "--config", configPath] : ["serve"] + const transport = new StdioClientTransport({ + command: "mcb", + args: serveArgs, + stderr: "pipe", + ...(env ? { env: { ...process.env, ...env, PATH: process.env.PATH ?? "" } } : {}), + }) + + const client = new Client({ name: "mcb-e2e-test", version: "0.1.0" }, { capabilities: {} }) + + await withTimeout(client.connect(transport), timeoutMs, "mcb_connect_timeout") + + return { + client, + transport, + close: async () => { + try { + await withTimeout(client.close(), timeoutMs, "mcb_close_timeout") + } catch { + await transport.close().catch(() => undefined) + } + }, + } +} + +export async function callMcbTool( + client: Client, + name: McbToolName, + args: Record, + timeoutMs = 5_000, +): Promise { + const meta = + typeof args._meta === "object" && args._meta !== null + ? (args._meta as Record) + : { + session_id: "ses_test", + project_id: "default", + worktree_id: "wt_test", + repo_path: process.cwd(), + machine_id: "machine_test", + agent_program: "oh-my-opencode", + model_id: "test-model", + } + const { _meta: _ignored, ...toolArgs } = args + try { + const result = await withTimeout( + client.callTool({ name, arguments: toolArgs, _meta: meta }), + timeoutMs, + `mcb_call_timeout:${name}`, + ) + const rawContent = Array.isArray(result.content) ? result.content : [] + return { + content: rawContent.map((item) => ({ + type: typeof item === "object" && item !== null && "type" in item && typeof item.type === "string" ? item.type : "text", + text: + typeof item === "object" && item !== null && "text" in item && typeof item.text === "string" ? item.text : "", + })), + isError: result.isError === true, + } + } catch (e) { + return { + content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], + isError: true, + } + } +} + +export function parseMcbToolResponse(result: McbCallToolResult): unknown { + const textItem = result.content.find((item) => item.type === "text") + const text = textItem?.text ?? "" + if (!text) { + return { text: "", isError: result.isError === true } + } + try { + return JSON.parse(text) + } catch { + return { text, isError: result.isError === true } + } +} + +export function createDefaultArgs(toolName: McbToolName): Record { + if (toolName === "memory") { + return { + action: "list", + resource: "observation", + data: {}, + ids: [], + project_id: "default", + repo_id: "", + session_id: "", + tags: [], + query: "", + anchor_id: "", + depth_before: 0, + depth_after: 0, + window_secs: 0, + observation_types: [], + max_tokens: 0, + limit: 10, + org_id: null, + } + } + if (toolName === "search") { + return { + query: "test", + resource: "memory", + collection: "default", + extensions: [], + filters: [], + limit: 10, + min_score: 0, + tags: [], + session_id: "", + token: "", + org_id: null, + } + } + if (toolName === "index") { + return { + action: "status", + path: ".", + collection: "default", + extensions: [], + exclude_dirs: [], + ignore_patterns: [], + max_file_size: 1024 * 1024, + follow_symlinks: false, + token: "", + } + } + if (toolName === "validate") { + return { + action: "list_rules", + scope: "project", + path: ".", + rules: [], + category: "", + } + } + if (toolName === "vcs") { + return { + action: "list_repositories", + repo_id: "", + repo_path: "", + base_branch: "", + target_branch: "", + query: "", + branches: [], + include_commits: false, + depth: 0, + limit: 10, + org_id: null, + } + } + if (toolName === "session") { + return { + action: "list", + session_id: "", + data: {}, + project_id: "default", + worktree_id: null, + agent_type: "", + status: "active", + limit: 10, + org_id: null, + } + } + return {} +} + +async function withTimeout(promise: Promise, timeoutMs: number, code: string): Promise { + let timeoutRef: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutRef = setTimeout(() => reject(new Error(code)), timeoutMs) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutRef) { + clearTimeout(timeoutRef) + } + } +} diff --git a/src/features/mcb-integration/mcb-roundtrip-helpers.test.ts b/src/features/mcb-integration/mcb-roundtrip-helpers.test.ts new file mode 100644 index 0000000000..b7247b1100 --- /dev/null +++ b/src/features/mcb-integration/mcb-roundtrip-helpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { discoverValidAgentType, waitForIndexReady } from "./mcb-roundtrip-helpers" + +const FAKE_CLIENT = {} as Client + +describe("mcb-integration/mcb-roundtrip-helpers", () => { + //#given session create attempts with failing then successful candidate + //#when discoverValidAgentType probes candidates + //#then it returns the first working candidate + it("returns first successful agent_type candidate", async () => { + const seenCandidates: string[] = [] + const discovered = await discoverValidAgentType(FAKE_CLIENT, { + candidates: ["alpha", "beta", "gamma"], + callTool: async (_client, _name, args) => { + seenCandidates.push(String(args.agent_type ?? "")) + return { content: [{ type: "text", text: "ok" }], isError: args.agent_type !== "gamma" } + }, + }) + + expect(discovered).toBe("gamma") + expect(seenCandidates).toEqual(["alpha", "beta", "gamma"]) + }) + + //#given all session create attempts fail + //#when discoverValidAgentType probes candidates + //#then it returns null + it("returns null when no agent_type candidate succeeds", async () => { + const discovered = await discoverValidAgentType(FAKE_CLIENT, { + candidates: ["alpha", "beta"], + callTool: async () => ({ content: [{ type: "text", text: "invalid" }], isError: true }), + }) + + expect(discovered).toBeNull() + }) + + //#given index status reports completion + //#when waitForIndexReady polls status + //#then it returns true + it("returns true when status includes ready keyword", async () => { + const ready = await waitForIndexReady(FAKE_CLIENT, "test-collection", { + intervalMs: 1, + maxWaitMs: 50, + callTool: async () => ({ content: [{ type: "text", text: "Indexing Status: Complete" }], isError: false }), + }) + + expect(ready).toBe(true) + }) + + //#given index status transitions from running to idle + //#when waitForIndexReady polls status + //#then it returns true after progress is observed + it("returns true after observing active state then idle", async () => { + let callCount = 0 + const ready = await waitForIndexReady(FAKE_CLIENT, "test-collection", { + intervalMs: 1, + maxWaitMs: 100, + callTool: async () => { + callCount += 1 + if (callCount === 1) { + return { content: [{ type: "text", text: "Indexing Status: Running" }], isError: false } + } + return { content: [{ type: "text", text: "Indexing Status: Idle" }], isError: false } + }, + }) + + expect(ready).toBe(true) + expect(callCount).toBeGreaterThanOrEqual(2) + }) + + //#given index status never becomes ready + //#when waitForIndexReady polls until timeout + //#then it returns false + it("returns false on timeout", async () => { + const ready = await waitForIndexReady(FAKE_CLIENT, "test-collection", { + intervalMs: 1, + maxWaitMs: 10, + callTool: async () => ({ content: [{ type: "text", text: "Indexing Status: Running" }], isError: false }), + }) + + expect(ready).toBe(false) + }) +}) diff --git a/src/features/mcb-integration/mcb-roundtrip-helpers.ts b/src/features/mcb-integration/mcb-roundtrip-helpers.ts new file mode 100644 index 0000000000..991948e5dc --- /dev/null +++ b/src/features/mcb-integration/mcb-roundtrip-helpers.ts @@ -0,0 +1,99 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { callMcbTool, createDefaultArgs, parseMcbToolResponse } from "./mcb-client-helper" +import type { McbCallToolResult, McbToolName } from "./types" + +const DEFAULT_AGENT_TYPE_CANDIDATES = [ + "sisyphus", "oracle", "explore", "prometheus", "momus", + "librarian", "metis", "sisyphus-junior", "hephaestus", + "atlas", "multimodal-looker", +] +const READY_KEYWORDS = ["complete", "completed", "ready", "done"] + +type CallToolFn = ( + client: Client, + name: McbToolName, + args: Record, + timeoutMs?: number, +) => Promise + +interface DiscoverAgentTypeOptions { + candidates?: readonly string[] + callTool?: CallToolFn + timeoutMs?: number +} + +interface WaitForIndexReadyOptions { + callTool?: CallToolFn + maxWaitMs?: number + intervalMs?: number + minIdleReadyMs?: number + timeoutMs?: number +} + +export async function discoverValidAgentType(client: Client, options: DiscoverAgentTypeOptions = {}): Promise { + const candidates = options.candidates ?? DEFAULT_AGENT_TYPE_CANDIDATES + const callTool = options.callTool ?? callMcbTool + const timeoutMs = options.timeoutMs ?? 5_000 + + for (const candidate of candidates) { + const args = { + ...createDefaultArgs("session"), + action: "create", + agent_type: candidate, + data: { name: `mcb-roundtrip-${Date.now()}` }, + } + try { + const result = await callTool(client, "session", args, timeoutMs) + if (result.isError !== true) { + return candidate + } + } catch {} + } + + return null +} + +export async function waitForIndexReady( + client: Client, + collection: string, + options: WaitForIndexReadyOptions = {}, +): Promise { + const callTool = options.callTool ?? callMcbTool + const maxWaitMs = options.maxWaitMs ?? 5_000 + const intervalMs = options.intervalMs ?? 200 + const minIdleReadyMs = options.minIdleReadyMs ?? 400 + const timeoutMs = options.timeoutMs ?? 5_000 + const startTime = Date.now() + let sawActiveState = false + + while (Date.now() - startTime < maxWaitMs) { + const args = { + ...createDefaultArgs("index"), + action: "status", + collection, + } + try { + const result = await callTool(client, "index", args, timeoutMs) + if (result.isError !== true) { + const parsed = parseMcbToolResponse(result) + const text = typeof parsed === "object" && parsed !== null && "text" in parsed ? String(parsed.text ?? "") : "" + const normalizedText = text.toLowerCase() + if (READY_KEYWORDS.some((keyword) => normalizedText.includes(keyword))) { + return true + } + if (normalizedText.includes("indexing") || normalizedText.includes("running")) { + sawActiveState = true + } + if (normalizedText.includes("idle")) { + if (sawActiveState || Date.now() - startTime >= minIdleReadyMs) { + return true + } + } + } + } catch {} + + await Bun.sleep(intervalMs) + } + + return false +} diff --git a/src/features/mcb-integration/recovery-sync.test.ts b/src/features/mcb-integration/recovery-sync.test.ts new file mode 100644 index 0000000000..12709cd0c7 --- /dev/null +++ b/src/features/mcb-integration/recovery-sync.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { getMcbAvailability, markMcbUnavailable, resetMcbAvailability } from "./availability" +import { attemptRecoverySync } from "./recovery-sync" +import { peekQueue, saveQueue } from "./sync-queue" +import type { QueuedMcbOperation } from "./sync-queue-types" + +const TEST_DIR = join(tmpdir(), `mcb-recovery-sync-test-${Date.now()}`) + +function makeOperation(overrides: Partial = {}): QueuedMcbOperation { + return { + id: `op-${Date.now()}-${Math.random()}`, + tool: "memory", + action: "store", + params: { value: "x" }, + queuedAt: Date.now(), + retryCount: 0, + maxRetries: 3, + source: "test", + ...overrides, + } +} + +describe("mcb-integration/recovery-sync", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + resetMcbAvailability() + }) + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + //#given queued operations + //#when recovery sync runs with successful executor + //#then operations are replayed and queue is cleared + it("replays queued operations and clears queue on success", async () => { + await saveQueue(TEST_DIR, [makeOperation({ id: "a" }), makeOperation({ id: "b" })]) + const replayed: string[] = [] + + const result = await attemptRecoverySync(TEST_DIR, async (operation) => { + replayed.push(operation.id) + }) + + expect(result.replayed).toBe(2) + expect(result.failed).toBe(0) + expect(result.discarded).toBe(0) + expect(replayed).toEqual(["a", "b"]) + expect(await peekQueue(TEST_DIR)).toEqual([]) + }) + + //#given unavailable tool and queued operation + //#when recovery replay succeeds + //#then tool availability is restored + it("marks tool available after successful replay", async () => { + markMcbUnavailable("memory") + await saveQueue(TEST_DIR, [makeOperation({ tool: "memory" })]) + + await attemptRecoverySync(TEST_DIR, async () => {}) + + const status = getMcbAvailability() + expect(status.tools.memory).toBe(true) + }) + + //#given a failing replay + //#when retry count is still below max + //#then operation remains queued with incremented retryCount + it("keeps failed operations with incremented retryCount", async () => { + await saveQueue(TEST_DIR, [makeOperation({ id: "x", retryCount: 0, maxRetries: 3 })]) + + const result = await attemptRecoverySync(TEST_DIR, async () => { + throw new Error("still down") + }) + + expect(result.replayed).toBe(0) + expect(result.failed).toBe(1) + expect(result.discarded).toBe(0) + const queue = await peekQueue(TEST_DIR) + expect(queue).toHaveLength(1) + expect(queue[0]?.retryCount).toBe(1) + }) + + //#given a failing replay at max retries + //#when recovery sync runs + //#then operation is discarded + it("discards operations that reached max retries", async () => { + await saveQueue(TEST_DIR, [makeOperation({ retryCount: 2, maxRetries: 3 })]) + + const result = await attemptRecoverySync(TEST_DIR, async () => { + throw new Error("still down") + }) + + expect(result.failed).toBe(1) + expect(result.discarded).toBe(1) + expect(await peekQueue(TEST_DIR)).toEqual([]) + }) + + //#given no queued operations + //#when recovery sync runs + //#then it is a no-op + it("returns no-op result for empty queue", async () => { + const result = await attemptRecoverySync(TEST_DIR, async () => {}) + expect(result).toEqual({ replayed: 0, failed: 0, discarded: 0 }) + }) +}) diff --git a/src/features/mcb-integration/recovery-sync.ts b/src/features/mcb-integration/recovery-sync.ts new file mode 100644 index 0000000000..34a9da8a8f --- /dev/null +++ b/src/features/mcb-integration/recovery-sync.ts @@ -0,0 +1,50 @@ +import { log } from "../../shared/logger" +import { markMcbAvailable } from "./availability" +import { evictStaleEntries, peekQueue, saveQueue } from "./sync-queue" +import type { QueuedMcbOperation } from "./sync-queue-types" + +export interface RecoverySyncResult { + replayed: number + failed: number + discarded: number +} + +export type McbOperationExecutor = (operation: QueuedMcbOperation) => Promise + +export async function attemptRecoverySync( + projectDir: string, + executor: McbOperationExecutor, +): Promise { + const result: RecoverySyncResult = { replayed: 0, failed: 0, discarded: 0 } + + await evictStaleEntries(projectDir) + const queue = await peekQueue(projectDir) + if (queue.length === 0) { + return result + } + + const remaining: QueuedMcbOperation[] = [] + + for (const operation of queue) { + try { + await executor(operation) + markMcbAvailable(operation.tool) + result.replayed++ + } catch { + const nextRetryCount = operation.retryCount + 1 + result.failed++ + if (nextRetryCount >= operation.maxRetries) { + result.discarded++ + continue + } + remaining.push({ + ...operation, + retryCount: nextRetryCount, + }) + } + } + + await saveQueue(projectDir, remaining) + log("[mcb] Recovery sync completed", result) + return result +} diff --git a/src/features/mcb-integration/session-lifecycle.test.ts b/src/features/mcb-integration/session-lifecycle.test.ts new file mode 100644 index 0000000000..1ca60cd255 --- /dev/null +++ b/src/features/mcb-integration/session-lifecycle.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test" +import { handleMcbSessionCreated } from "./session-lifecycle" +import { resetMcbAvailability, markMcbUnavailable } from "./availability" +import type { McbOperationExecutor } from "./recovery-sync" + +describe("handleMcbSessionCreated", () => { + beforeEach(() => { + resetMcbAvailability() + }) + + test("resets warning state on session created", () => { + //#given + const executor: McbOperationExecutor = mock(async () => {}) + + //#when + handleMcbSessionCreated("/tmp/test-project", executor) + + //#then - no throw, warning state is reset internally + expect(true).toBe(true) + }) + + test("skips recovery sync when MCB is globally unavailable", () => { + //#given + markMcbUnavailable() + const executor: McbOperationExecutor = mock(async () => {}) + + //#when + handleMcbSessionCreated("/tmp/test-project", executor) + + //#then - executor should NOT be called since MCB is unavailable + expect(executor).not.toHaveBeenCalled() + }) + + test("fires recovery sync without blocking when MCB is available", async () => { + //#given + const executor: McbOperationExecutor = mock(async () => {}) + + //#when + handleMcbSessionCreated("/tmp/test-project", executor) + + //#then - function returns immediately (fire-and-forget) + // Recovery runs async but does not throw or block + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(true).toBe(true) + }) + + test("catches recovery sync errors without propagating", async () => { + //#given - executor that would throw (but recovery has no queued ops by default) + const executor: McbOperationExecutor = mock(async () => { + throw new Error("MCB connection failed") + }) + + //#when - should not throw even if executor fails + handleMcbSessionCreated("/tmp/test-project", executor) + + //#then - no error propagated, function returns cleanly + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(true).toBe(true) + }) +}) diff --git a/src/features/mcb-integration/session-lifecycle.ts b/src/features/mcb-integration/session-lifecycle.ts new file mode 100644 index 0000000000..fc92a6a01c --- /dev/null +++ b/src/features/mcb-integration/session-lifecycle.ts @@ -0,0 +1,17 @@ +import { log } from "../../shared/logger" +import { resetWarningState } from "./degradation-warnings" +import { attemptRecoverySync, type McbOperationExecutor } from "./recovery-sync" +import { getMcbAvailability } from "./availability" + +export function handleMcbSessionCreated(projectDir: string, executor: McbOperationExecutor): void { + resetWarningState() + + const status = getMcbAvailability() + if (!status.available) { + return + } + + attemptRecoverySync(projectDir, executor).catch((error) => { + log("[mcb] Recovery sync failed on session.created", error instanceof Error ? error.message : String(error)) + }) +} diff --git a/src/features/mcb-integration/sync-queue-types.ts b/src/features/mcb-integration/sync-queue-types.ts new file mode 100644 index 0000000000..50874d7648 --- /dev/null +++ b/src/features/mcb-integration/sync-queue-types.ts @@ -0,0 +1,20 @@ +import type { McbToolAvailability } from "./types" + +export interface QueuedMcbOperation { + id: string + tool: keyof McbToolAvailability + action: string + params: Record + queuedAt: number + retryCount: number + maxRetries: number + source: string + sessionId?: string +} + +export interface SyncQueueConfig { + maxEntries: number + maxAgeMs: number + queueDir: string + queueFile: string +} diff --git a/src/features/mcb-integration/sync-queue.test.ts b/src/features/mcb-integration/sync-queue.test.ts new file mode 100644 index 0000000000..7fa4973e4f --- /dev/null +++ b/src/features/mcb-integration/sync-queue.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { + clearQueue, + dequeueOperation, + enqueueOperation, + evictStaleEntries, + getQueueSize, + peekQueue, + saveQueue, +} from "./sync-queue" +import type { QueuedMcbOperation } from "./sync-queue-types" + +const TEST_DIR = join(tmpdir(), `mcb-sync-queue-test-${Date.now()}`) + +function makeOperation(overrides: Partial = {}): QueuedMcbOperation { + return { + id: `op-${Date.now()}-${Math.random()}`, + tool: "memory", + action: "store", + params: { value: "x" }, + queuedAt: Date.now(), + retryCount: 0, + maxRetries: 3, + source: "test", + ...overrides, + } +} + +describe("mcb-integration/sync-queue", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + }) + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + //#given an empty queue + //#when enqueueOperation is called + //#then queue size increases + it("enqueues an operation", async () => { + await enqueueOperation(TEST_DIR, makeOperation()) + expect(await getQueueSize(TEST_DIR)).toBe(1) + }) + + //#given a queue with one item + //#when dequeueOperation is called + //#then the operation is returned and removed + it("dequeues an operation", async () => { + const op = makeOperation() + await enqueueOperation(TEST_DIR, op) + const dequeued = await dequeueOperation(TEST_DIR) + expect(dequeued?.id).toBe(op.id) + expect(await getQueueSize(TEST_DIR)).toBe(0) + }) + + //#given queued operations + //#when peekQueue is called + //#then all operations are returned without removal + it("peeks queue without removing items", async () => { + await enqueueOperation(TEST_DIR, makeOperation({ id: "a" })) + await enqueueOperation(TEST_DIR, makeOperation({ id: "b" })) + const queue = await peekQueue(TEST_DIR) + expect(queue.map((q) => q.id)).toEqual(["a", "b"]) + expect(await getQueueSize(TEST_DIR)).toBe(2) + }) + + //#given queue entries + //#when saveQueue is called + //#then queue file is written atomically + it("saves queue entries to disk", async () => { + await saveQueue(TEST_DIR, [makeOperation({ id: "one" })]) + const queuePath = join(TEST_DIR, ".sisyphus", ".mcb-sync-queue.json") + expect(existsSync(queuePath)).toBe(true) + const parsed = JSON.parse(readFileSync(queuePath, "utf-8")) + expect(parsed[0].id).toBe("one") + }) + + //#given a bounded queue config + //#when enqueueOperation exceeds max entries + //#then oldest entries are evicted + it("evicts oldest entries when maxEntries is exceeded", async () => { + const config = { maxEntries: 2, maxAgeMs: 60_000, queueDir: ".sisyphus", queueFile: ".mcb-sync-queue.json" } + await enqueueOperation(TEST_DIR, makeOperation({ id: "first" }), config) + await enqueueOperation(TEST_DIR, makeOperation({ id: "second" }), config) + await enqueueOperation(TEST_DIR, makeOperation({ id: "third" }), config) + const queue = await peekQueue(TEST_DIR, config) + expect(queue.map((q) => q.id)).toEqual(["second", "third"]) + }) + + //#given stale and fresh operations + //#when evictStaleEntries is called + //#then stale items are removed + it("evicts stale entries", async () => { + const now = Date.now() + const config = { maxEntries: 10, maxAgeMs: 1_000, queueDir: ".sisyphus", queueFile: ".mcb-sync-queue.json" } + await saveQueue(TEST_DIR, [ + makeOperation({ id: "old", queuedAt: now - 2_000 }), + makeOperation({ id: "new", queuedAt: now }), + ], config) + const evicted = await evictStaleEntries(TEST_DIR, config) + expect(evicted).toBe(1) + const queue = await peekQueue(TEST_DIR, config) + expect(queue.map((q) => q.id)).toEqual(["new"]) + }) + + //#given a corrupt queue file + //#when peekQueue is called + //#then an empty queue is returned + it("returns empty queue for corrupt queue file", async () => { + const queueDir = join(TEST_DIR, ".sisyphus") + mkdirSync(queueDir, { recursive: true }) + writeFileSync(join(queueDir, ".mcb-sync-queue.json"), "{invalid-json", "utf-8") + const queue = await peekQueue(TEST_DIR) + expect(queue).toEqual([]) + }) + + //#given queue contents + //#when clearQueue is called + //#then queue file is removed + it("clears queue file", async () => { + await enqueueOperation(TEST_DIR, makeOperation()) + clearQueue(TEST_DIR) + const queuePath = join(TEST_DIR, ".sisyphus", ".mcb-sync-queue.json") + expect(existsSync(queuePath)).toBe(false) + }) +}) diff --git a/src/features/mcb-integration/sync-queue.ts b/src/features/mcb-integration/sync-queue.ts new file mode 100644 index 0000000000..5b57c61ff8 --- /dev/null +++ b/src/features/mcb-integration/sync-queue.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs" +import { readFile } from "fs/promises" +import { join } from "path" +import { log } from "../../shared/logger" +import type { QueuedMcbOperation, SyncQueueConfig } from "./sync-queue-types" + +export const DEFAULT_SYNC_QUEUE_CONFIG: SyncQueueConfig = { + maxEntries: 500, + maxAgeMs: 604_800_000, + queueDir: ".sisyphus", + queueFile: ".mcb-sync-queue.json", +} + +function getQueuePath(projectDir: string, config = DEFAULT_SYNC_QUEUE_CONFIG): string { + return join(projectDir, config.queueDir, config.queueFile) +} + +function parseQueue(raw: string): QueuedMcbOperation[] { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] +} + +function writeQueueAtomic(queuePath: string, entries: QueuedMcbOperation[]): void { + const tmpPath = `${queuePath}.tmp` + writeFileSync(tmpPath, JSON.stringify(entries, null, 2), "utf-8") + renameSync(tmpPath, queuePath) +} + +export async function peekQueue( + projectDir: string, + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queuePath = getQueuePath(projectDir, config) + if (!existsSync(queuePath)) { + return [] + } + + try { + const raw = await readFile(queuePath, "utf-8") + return parseQueue(raw) + } catch (error) { + log("[mcb] Failed to read sync queue, using empty queue", { + queuePath, + error: error instanceof Error ? error.message : String(error), + }) + return [] + } +} + +export async function getQueueSize( + projectDir: string, + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queue = await peekQueue(projectDir, config) + return queue.length +} + +export async function enqueueOperation( + projectDir: string, + operation: QueuedMcbOperation, + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queuePath = getQueuePath(projectDir, config) + const queueDir = join(projectDir, config.queueDir) + if (!existsSync(queueDir)) { + mkdirSync(queueDir, { recursive: true }) + } + + const existing = await peekQueue(projectDir, config) + const fresh = existing.filter((entry) => Date.now() - entry.queuedAt <= config.maxAgeMs) + const next = [...fresh, operation] + while (next.length > config.maxEntries) { + next.shift() + } + writeQueueAtomic(queuePath, next) +} + +export async function dequeueOperation( + projectDir: string, + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queuePath = getQueuePath(projectDir, config) + const queue = await peekQueue(projectDir, config) + const item = queue.shift() ?? null + if (item) { + writeQueueAtomic(queuePath, queue) + } + return item +} + +export async function saveQueue( + projectDir: string, + entries: QueuedMcbOperation[], + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queueDir = join(projectDir, config.queueDir) + if (!existsSync(queueDir)) { + mkdirSync(queueDir, { recursive: true }) + } + const queuePath = getQueuePath(projectDir, config) + writeQueueAtomic(queuePath, entries) +} + +export async function evictStaleEntries( + projectDir: string, + config = DEFAULT_SYNC_QUEUE_CONFIG, +): Promise { + const queue = await peekQueue(projectDir, config) + const fresh = queue.filter((entry) => Date.now() - entry.queuedAt <= config.maxAgeMs) + const evicted = queue.length - fresh.length + if (evicted > 0) { + await saveQueue(projectDir, fresh, config) + } + return evicted +} + +export function clearQueue(projectDir: string, config = DEFAULT_SYNC_QUEUE_CONFIG): void { + rmSync(getQueuePath(projectDir, config), { force: true }) +} diff --git a/src/features/mcb-integration/test-mcb.toml b/src/features/mcb-integration/test-mcb.toml new file mode 100644 index 0000000000..0197122543 --- /dev/null +++ b/src/features/mcb-integration/test-mcb.toml @@ -0,0 +1,40 @@ +[logging] +level = "WARN" + +[mode] +type = "standalone" + +[server] +transport_mode = "stdio" + +[server.network] +host = "127.0.0.1" +port = 18080 + +[providers.embedding] +provider = "fastembed" + +[providers.embedding.configs.default] +provider = "fastembed" +model = "AllMiniLML6V2" +dimensions = 384 + +[providers.vector_store] +provider = "edgevec" + +[providers.vector_store.configs.default] +provider = "edgevec" +dimensions = 384 + +[system.infrastructure.cache] +enabled = true +provider = "Moka" +default_ttl_secs = 3600 +max_size = 100000 + +[system.infrastructure.event_bus] +provider = "tokio" +capacity = 1024 + +[auth] +enabled = false diff --git a/src/features/mcb-integration/types.ts b/src/features/mcb-integration/types.ts new file mode 100644 index 0000000000..0b1d98f2a2 --- /dev/null +++ b/src/features/mcb-integration/types.ts @@ -0,0 +1,138 @@ +export const MCB_TOOL_NAMES = [ + "agent", + "entity", + "index", + "memory", + "project", + "search", + "session", + "validate", + "vcs", +] as const + +export type McbToolName = (typeof MCB_TOOL_NAMES)[number] +export type McbSearchResource = "code" | "memory" | "context" +export type McbMemoryAction = "store" | "get" | "list" | "timeline" | "inject" +export type McbMemoryResource = "observation" | "execution" | "quality_gate" | "error_pattern" | "session" +export type McbIndexAction = "start" | "status" | "clear" +export type McbValidateAction = "run" | "list_rules" | "analyze" +export type McbValidateScope = "file" | "project" +export type McbVcsAction = + | "list_repositories" + | "index_repository" + | "compare_branches" + | "search_branch" + | "analyze_impact" +export type McbSessionAction = "create" | "get" | "update" | "list" | "summarize" + +export interface McbSearchArgs { + query: string + resource: McbSearchResource + collection: string + extensions: string[] + filters: string[] + limit: number + min_score: number + tags: string[] + session_id: string + token: string + org_id?: string | null +} + +export interface McbMemoryArgs { + action: McbMemoryAction + resource: McbMemoryResource + data: Record + ids: string[] + project_id: string + repo_id: string + session_id: string + tags: string[] + query: string + anchor_id: string + depth_before: number + depth_after: number + window_secs: number + observation_types: string[] + max_tokens: number + limit: number + org_id?: string | null +} + +export interface McbIndexArgs { + action: McbIndexAction + path: string + collection: string + extensions: string[] + exclude_dirs: string[] + ignore_patterns: string[] + max_file_size: number + follow_symlinks: boolean + token: string +} + +export interface McbValidateArgs { + action: McbValidateAction + scope: McbValidateScope + path: string + rules: string[] + category: string +} + +export interface McbVcsArgs { + action: McbVcsAction + repo_id: string + repo_path: string + base_branch: string + target_branch: string + query: string + branches: string[] + include_commits: boolean + depth: number + limit: number + org_id?: string | null +} + +export interface McbSessionArgs { + action: McbSessionAction + session_id: string + data: Record + project_id: string + worktree_id: string + agent_type: string + status: string + limit: number + org_id?: string | null +} + +export interface McbTextContent { + type: string + text: string +} + +export interface McbCallToolResult { + content: McbTextContent[] + isError?: boolean +} + +export type McbSearchParams = McbSearchArgs +export interface McbMemoryStoreParams extends McbMemoryArgs { + action: "store" +} +export type McbIndexParams = McbIndexArgs +export type McbValidateParams = McbValidateArgs + +export interface McbToolAvailability { + search: boolean + memory: boolean + index: boolean + validate: boolean + vcs: boolean + session: boolean +} + +export interface McbAvailabilityStatus { + available: boolean + checkedAt: number + tools: McbToolAvailability +} diff --git a/src/hooks/ambiguity-detector/hook.ts b/src/hooks/ambiguity-detector/hook.ts new file mode 100644 index 0000000000..e8f41dbfd9 --- /dev/null +++ b/src/hooks/ambiguity-detector/hook.ts @@ -0,0 +1,31 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { detectAmbiguity, extractPromptText } from "./patterns" +import type { ChatMessageInput, ChatMessageOutput } from "./types" + +function buildClarificationGuidance(reasons: string[]): string { + const reasonText = reasons.length > 0 ? reasons.join(", ") : "insufficient context" + return [ + "Clarification needed before implementation:", + `Detected ambiguity signals: ${reasonText}.`, + "Please include: target file/function, expected outcome, and measurable success criteria.", + ].join("\n") +} + +export function createAmbiguityDetectorHook(_ctx: PluginInput) { + return { + "chat.message": async (input: ChatMessageInput, output: ChatMessageOutput): Promise => { + if (!input.sessionID) return + + const promptText = extractPromptText(output.parts) + if (!promptText) return + + const result = detectAmbiguity(promptText) + if (!result.ambiguous) return + + output.parts.push({ + type: "text", + text: buildClarificationGuidance(result.reasons), + }) + }, + } +} diff --git a/src/hooks/ambiguity-detector/index.test.ts b/src/hooks/ambiguity-detector/index.test.ts new file mode 100644 index 0000000000..8224ab02cf --- /dev/null +++ b/src/hooks/ambiguity-detector/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import { createAmbiguityDetectorHook } from "./index" + +describe("ambiguity-detector", () => { + const createOutput = (text: string) => ({ + message: {} as Record, + parts: [{ type: "text", text }], + }) + + it("#given a vague user message without specific targets #when processed #then injects clarification guidance", async () => { + //#given a vague user message without specific targets + const hook = createAmbiguityDetectorHook({} as any) + const output = createOutput("fix this quickly") + + //#when the ambiguity detector processes it + await hook["chat.message"]({ sessionID: "s1" }, output) + + //#then it should inject clarification guidance + expect(output.parts).toHaveLength(2) + expect(output.parts[1].type).toBe("text") + expect(output.parts[1].text).toContain("Clarification needed") + }) + + it("#given a specific user message with file path #when processed #then does not inject guidance", async () => { + //#given a specific user message with file paths + const hook = createAmbiguityDetectorHook({} as any) + const output = createOutput("Update src/plugin/chat-message.ts:79 to call new hook") + + //#when the ambiguity detector processes it + await hook["chat.message"]({ sessionID: "s1" }, output) + + //#then it should NOT inject any guidance + expect(output.parts).toHaveLength(1) + expect(output.parts[0].text).toBe("Update src/plugin/chat-message.ts:79 to call new hook") + }) + + it("#given message with no text parts #when processed #then does nothing", async () => { + const hook = createAmbiguityDetectorHook({} as any) + const output = { + message: {} as Record, + parts: [{ type: "tool_use" as const }], + } + + await hook["chat.message"]({ sessionID: "s1" }, output) + + expect(output.parts).toHaveLength(1) + expect(output.parts[0].type).toBe("tool_use") + }) +}) diff --git a/src/hooks/ambiguity-detector/index.ts b/src/hooks/ambiguity-detector/index.ts new file mode 100644 index 0000000000..63d2ebed1e --- /dev/null +++ b/src/hooks/ambiguity-detector/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./patterns" +export { createAmbiguityDetectorHook } from "./hook" diff --git a/src/hooks/ambiguity-detector/patterns.ts b/src/hooks/ambiguity-detector/patterns.ts new file mode 100644 index 0000000000..02efdc1280 --- /dev/null +++ b/src/hooks/ambiguity-detector/patterns.ts @@ -0,0 +1,75 @@ +import type { AmbiguityReason, AmbiguityResult, MessagePart } from "./types" + +export const VAGUE_ACTION_VERBS = [ + "fix", + "update", + "change", + "improve", + "optimize", + "refactor", + "clean", +] + +export const VAGUE_REQUIREMENT_PHRASES = [ + "make it better", + "improve this", + "optimize this", + "make it faster", + "clean this up", +] + +const FILE_PATH_PATTERN = + /(?:\b[\w.-]+\/[\w./-]*\.[\w]+(?::\d+(?::\d+)?)?|\b[\w.-]+\.[\w]+(?::\d+(?::\d+)?)?)/i +const LINE_REFERENCE_PATTERN = /#L\d+(?:C\d+)?|\bline\s+\d+\b|:\d+(?::\d+)?/i +const FUNCTION_PATTERN = /\b[a-zA-Z_]\w*\s*\(/i +const METRIC_PATTERN = /\b\d+(?:\.\d+)?\s*(?:ms|s|sec|seconds?|minutes?|%|percent|kb|mb|gb|x)\b/i +const MULTIPLE_INTERPRETATION_PATTERN = /\b(?:or|maybe|something|whatever|somehow|etc)\b/i + +export function extractPromptText(parts: MessagePart[]): string { + return parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text?.trim() ?? "") + .filter(Boolean) + .join("\n") + .trim() +} + +export function detectAmbiguity(text: string): AmbiguityResult { + const lower = text.toLowerCase() + const words = lower.split(/\s+/).filter(Boolean) + const hasFilePath = FILE_PATH_PATTERN.test(text) + const hasLineReference = LINE_REFERENCE_PATTERN.test(text) + const hasFunction = FUNCTION_PATTERN.test(text) + const hasScope = hasFilePath || hasLineReference || hasFunction + const hasVagueVerb = VAGUE_ACTION_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`, "i").test(lower)) + const hasVaguePhrase = VAGUE_REQUIREMENT_PHRASES.some((phrase) => lower.includes(phrase)) + const hasMetric = METRIC_PATTERN.test(lower) + + const reasons: AmbiguityReason[] = [] + + if (words.length > 0 && words.length < 15 && !hasFilePath) { + reasons.push("short-prompt") + } + + if (hasVagueVerb && !hasScope) { + reasons.push("missing-goal") + } + + if (!hasScope) { + reasons.push("missing-scope") + } + + if (hasVaguePhrase && !hasMetric) { + reasons.push("vague-requirements") + } + + if (MULTIPLE_INTERPRETATION_PATTERN.test(lower) && !hasScope) { + reasons.push("multiple-interpretations") + } + + const uniqueReasons = [...new Set(reasons)] + return { + ambiguous: uniqueReasons.length > 0, + reasons: uniqueReasons, + } +} diff --git a/src/hooks/ambiguity-detector/types.ts b/src/hooks/ambiguity-detector/types.ts new file mode 100644 index 0000000000..1eba57b6a3 --- /dev/null +++ b/src/hooks/ambiguity-detector/types.ts @@ -0,0 +1,29 @@ +export type AmbiguityReason = + | "short-prompt" + | "missing-goal" + | "missing-scope" + | "vague-requirements" + | "multiple-interpretations" + +export type AmbiguityResult = { + ambiguous: boolean + reasons: AmbiguityReason[] +} + +export type ChatMessageInput = { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string +} + +export type MessagePart = { + type: string + text?: string + [key: string]: unknown +} + +export type ChatMessageOutput = { + message: Record + parts: MessagePart[] +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9544752778..9cae449615 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -21,6 +21,7 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker"; export { createAgentUsageReminderHook } from "./agent-usage-reminder"; export { createKeywordDetectorHook } from "./keyword-detector"; +export { createAmbiguityDetectorHook } from "./ambiguity-detector"; export { createNonInteractiveEnvHook } from "./non-interactive-env"; export { createInteractiveBashSessionHook } from "./interactive-bash-session"; @@ -44,3 +45,4 @@ export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; +export { createWisdomCaptureHook } from "./wisdom-capture"; diff --git a/src/hooks/wisdom-capture/hook.ts b/src/hooks/wisdom-capture/hook.ts new file mode 100644 index 0000000000..3b5a22966a --- /dev/null +++ b/src/hooks/wisdom-capture/hook.ts @@ -0,0 +1,46 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { withMcbFallback } from "../../features/mcb-integration" +import { detectLearning, detectOutcome } from "./learning-detector" +import type { ToolOutcome, WisdomCaptureOptions, ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" + +export function createWisdomCaptureHook(ctx: PluginInput, options: WisdomCaptureOptions = {}) { + const outcomeByTool = new Map() + + return { + "tool.execute.after": async ( + input: ToolExecuteAfterInput, + output: ToolExecuteAfterOutput, + ): Promise => { + const key = `${input.sessionID}:${input.tool.toLowerCase()}` + const previousOutcome = outcomeByTool.get(key) ?? "unknown" + const learning = detectLearning({ input, output, previousOutcome }) + const currentOutcome = detectOutcome(output.output) + outcomeByTool.set(key, currentOutcome) + + if (!learning) return + + await withMcbFallback( + async () => { + if (options.storeLearning) { + await options.storeLearning(learning) + } + return learning + }, + "memory", + { + tool: "memory", + action: "store", + params: { + kind: learning.kind, + summary: learning.summary, + tool: learning.tool, + }, + maxRetries: 3, + source: "wisdom-capture", + sessionId: input.sessionID, + }, + ctx.directory, + ) + }, + } +} diff --git a/src/hooks/wisdom-capture/index.test.ts b/src/hooks/wisdom-capture/index.test.ts new file mode 100644 index 0000000000..ea56e1f28a --- /dev/null +++ b/src/hooks/wisdom-capture/index.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" +import { markMcbUnavailable, resetMcbAvailability } from "../../features/mcb-integration" +import { createWisdomCaptureHook } from "./index" + +describe("wisdom-capture", () => { + beforeEach(() => { + resetMcbAvailability() + }) + + const createInput = (tool = "Bash") => ({ + tool, + sessionID: "session-1", + callID: "call-1", + }) + + const createOutput = (text: string) => ({ + title: "Tool output", + output: text, + metadata: {} as Record, + }) + + it("#given a failed attempt then successful approach #when processed #then captures error correction wisdom", async () => { + //#given a failed attempt then successful approach + const storeLearning = mock((_learning: any) => Promise.resolve()) + const hook = createWisdomCaptureHook({} as any, { storeLearning }) + + await hook["tool.execute.after"](createInput("Bash"), createOutput("Error: command failed")) + + //#when the successful retry is processed + await hook["tool.execute.after"]( + { ...createInput("Bash"), callID: "call-2" }, + createOutput("Command succeeded using a different approach"), + ) + + //#then it should capture an error-correction learning + expect(storeLearning).toHaveBeenCalledTimes(1) + const learning = storeLearning.mock.calls[0]?.[0] + expect(learning?.kind).toBe("error-correction") + }) + + it("#given output with naming convention discovery #when processed #then captures pattern discovery wisdom", async () => { + const storeLearning = mock((_learning: any) => Promise.resolve()) + const hook = createWisdomCaptureHook({} as any, { storeLearning }) + + await hook["tool.execute.after"]( + createInput("Read"), + createOutput("Discovered naming convention: createXHook factory pattern"), + ) + + expect(storeLearning).toHaveBeenCalledTimes(1) + const learning = storeLearning.mock.calls[0]?.[0] + expect(learning?.kind).toBe("pattern-discovery") + }) + + it("#given mcb is unavailable #when learning is detected #then degrades silently without storing", async () => { + const storeLearning = mock((_learning: any) => Promise.resolve()) + const hook = createWisdomCaptureHook({} as any, { storeLearning }) + markMcbUnavailable() + + await hook["tool.execute.after"]( + createInput("Read"), + createOutput("Discovered naming convention: createXHook factory pattern"), + ) + + expect(storeLearning).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/wisdom-capture/index.ts b/src/hooks/wisdom-capture/index.ts new file mode 100644 index 0000000000..7630d1da3f --- /dev/null +++ b/src/hooks/wisdom-capture/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./learning-detector" +export { createWisdomCaptureHook } from "./hook" diff --git a/src/hooks/wisdom-capture/learning-detector.ts b/src/hooks/wisdom-capture/learning-detector.ts new file mode 100644 index 0000000000..5d3e75e48f --- /dev/null +++ b/src/hooks/wisdom-capture/learning-detector.ts @@ -0,0 +1,65 @@ +import type { LearningRecord, ToolExecuteAfterInput, ToolExecuteAfterOutput, ToolOutcome } from "./types" + +const FAILURE_MARKERS = ["error", "failed", "exception", "cannot", "not found"] +const SUCCESS_MARKERS = ["success", "succeeded", "completed", "fixed", "resolved"] +const PATTERN_MARKERS = ["pattern", "convention", "naming", "factory", "best practice"] +const APPROACH_MARKERS = ["approach", "strategy", "using", "workaround", "method"] + +function normalize(text: string): string { + return text.toLowerCase() +} + +function includesAny(text: string, markers: string[]): boolean { + return markers.some((marker) => text.includes(marker)) +} + +export function detectOutcome(outputText: string): ToolOutcome { + const normalized = normalize(outputText) + if (includesAny(normalized, FAILURE_MARKERS)) return "failed" + if (includesAny(normalized, SUCCESS_MARKERS)) return "succeeded" + return "unknown" +} + +export function detectLearning(args: { + input: ToolExecuteAfterInput + output: ToolExecuteAfterOutput + previousOutcome: ToolOutcome +}): LearningRecord | null { + const normalized = normalize(args.output.output) + const currentOutcome = detectOutcome(args.output.output) + + if (args.previousOutcome === "failed" && currentOutcome === "succeeded") { + return { + kind: "error-correction", + sessionID: args.input.sessionID, + tool: args.input.tool, + summary: "Recovered from tool failure using a revised approach", + evidence: args.output.output.slice(0, 300), + capturedAt: new Date().toISOString(), + } + } + + if (includesAny(normalized, PATTERN_MARKERS)) { + return { + kind: "pattern-discovery", + sessionID: args.input.sessionID, + tool: args.input.tool, + summary: "Detected a reusable file, naming, or implementation pattern", + evidence: args.output.output.slice(0, 300), + capturedAt: new Date().toISOString(), + } + } + + if (currentOutcome === "succeeded" && includesAny(normalized, APPROACH_MARKERS)) { + return { + kind: "successful-approach", + sessionID: args.input.sessionID, + tool: args.input.tool, + summary: "Captured a successful approach worth reusing", + evidence: args.output.output.slice(0, 300), + capturedAt: new Date().toISOString(), + } + } + + return null +} diff --git a/src/hooks/wisdom-capture/types.ts b/src/hooks/wisdom-capture/types.ts new file mode 100644 index 0000000000..8093445490 --- /dev/null +++ b/src/hooks/wisdom-capture/types.ts @@ -0,0 +1,28 @@ +export type ToolExecuteAfterInput = { + tool: string + sessionID: string + callID: string +} + +export type ToolExecuteAfterOutput = { + title: string + output: string + metadata: Record +} + +export type ToolOutcome = "failed" | "succeeded" | "unknown" + +export type LearningKind = "error-correction" | "pattern-discovery" | "successful-approach" + +export type LearningRecord = { + kind: LearningKind + sessionID: string + tool: string + summary: string + evidence: string + capturedAt: string +} + +export type WisdomCaptureOptions = { + storeLearning?: (learning: LearningRecord) => Promise +} diff --git a/src/index.ts b/src/index.ts index 69cae8cd5e..509e1723f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { createPluginInterface } from "./plugin-interface" import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" +import { initializeMcbFromConfig } from "./features/mcb-integration" import { injectServerAuthIntoClient, log } from "./shared" import { startTmuxCheck } from "./tools" @@ -22,6 +23,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { startTmuxCheck() const pluginConfig = loadPluginConfig(ctx.directory, ctx) + initializeMcbFromConfig(pluginConfig.mcb) const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []) const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName) diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index 8cc1b394d6..2fdbe11f5e 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -76,6 +76,7 @@ export function createChatMessageHandler(args: { } await hooks.stopContinuationGuard?.["chat.message"]?.(input) + await hooks.ambiguityDetector?.["chat.message"]?.(input, output) await hooks.keywordDetector?.["chat.message"]?.(input, output) await hooks.claudeCodeHooks?.["chat.message"]?.(input, output) await hooks.autoSlashCommand?.["chat.message"]?.(input, output) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 3ab1cd41d8..6a19ed92fe 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -7,6 +7,7 @@ import { setMainSession, updateSessionAgent, } from "../features/claude-code-session-state" +import { handleMcbSessionCreated, type McbOperationExecutor } from "../features/mcb-integration" import { resetMessageCursor } from "../shared" import { lspManager } from "../tools" @@ -103,6 +104,15 @@ export function createEventHandler(args: { firstMessageVariantGate.markSessionCreated(sessionInfo) + const mcbExecutor: McbOperationExecutor = async (op) => { + const info = { serverName: "mcb", skillName: "oc-mcb", sessionID: sessionInfo?.id ?? "main" } + const mcbConfig = { command: "mcb", args: ["serve"] } + const context = { config: mcbConfig, skillName: "oc-mcb" } + const toolName = `mcb_${op.tool}` + await managers.skillMcpManager.callTool(info, context, toolName, { action: op.action, ...op.params }) + } + handleMcbSessionCreated(ctx.directory, mcbExecutor) + await managers.tmuxSessionManager.onSessionCreated( event as { type: string diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 28a0ecc32f..bf8d9275bc 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -21,6 +21,8 @@ import { createQuestionLabelTruncatorHook, createSubagentQuestionBlockerHook, createPreemptiveCompactionHook, + createAmbiguityDetectorHook, + createWisdomCaptureHook, } from "../../hooks" import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" import { @@ -52,6 +54,8 @@ export type SessionHooks = { subagentQuestionBlocker: ReturnType taskResumeInfo: ReturnType anthropicEffort: ReturnType | null + ambiguityDetector: ReturnType | null + wisdomCapture: ReturnType | null } export function createSessionHooks(args: { @@ -156,6 +160,14 @@ export function createSessionHooks(args: { ? safeHook("anthropic-effort", () => createAnthropicEffortHook()) : null + const ambiguityDetector = isHookEnabled("ambiguity-detector") + ? safeHook("ambiguity-detector", () => createAmbiguityDetectorHook(ctx)) + : null + + const wisdomCapture = isHookEnabled("wisdom-capture") + ? safeHook("wisdom-capture", () => createWisdomCaptureHook(ctx)) + : null + return { contextWindowMonitor, preemptiveCompaction, @@ -177,5 +189,7 @@ export function createSessionHooks(args: { subagentQuestionBlocker, taskResumeInfo, anthropicEffort, + ambiguityDetector, + wisdomCapture, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 21282e3d36..e4d1c3ba3d 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -41,6 +41,7 @@ export function createToolExecuteAfterHandler(args: { await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output) await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output) await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) + await hooks.wisdomCapture?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 7236ddc489..2d2163f33e 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -25,6 +25,7 @@ import { createTaskGetTool, createTaskList, createTaskUpdateTool, + createAgentTeamsTools, } from "../tools" import { getMainSessionID } from "../features/claude-code-session-state" import { filterDisabledTools } from "../shared/disabled-tools" @@ -131,6 +132,11 @@ export function createToolRegistry(args: { skill_mcp: skillMcpTool, slashcommand: slashcommandTool, interactive_bash, + ...createAgentTeamsTools(managers.backgroundManager, { + client: ctx.client, + userCategories: pluginConfig.categories, + sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, + }), ...taskToolsRecord, } diff --git a/src/tools/agent-teams/delegation-consistency.test.ts b/src/tools/agent-teams/delegation-consistency.test.ts new file mode 100644 index 0000000000..32a2ae4b37 --- /dev/null +++ b/src/tools/agent-teams/delegation-consistency.test.ts @@ -0,0 +1,190 @@ +/// +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 type { BackgroundManager } from "../../features/background-agent" +import { createAgentTeamsTools } from "./tools" + +interface LaunchCall { + description: string + prompt: string + agent: string + parentSessionID: string + parentMessageID: string + parentAgent?: string + parentModel?: { + providerID: string + modelID: string + variant?: string + } +} + +interface ResumeCall { + sessionId: string + prompt: string + parentSessionID: string + parentMessageID: string + parentAgent?: string + parentModel?: { + providerID: string + modelID: string + variant?: string + } +} + +interface ToolContextLike { + sessionID: string + messageID: string + abort: AbortSignal + agent?: string +} + +function createMockManager(): { + manager: BackgroundManager + launchCalls: LaunchCall[] + resumeCalls: ResumeCall[] +} { + const launchCalls: LaunchCall[] = [] + const resumeCalls: ResumeCall[] = [] + const launchedTasks = new Map() + let launchCount = 0 + + const manager = { + launch: async (args: LaunchCall) => { + launchCount += 1 + launchCalls.push(args) + const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` } + launchedTasks.set(task.id, task) + return task + }, + getTask: (taskId: string) => launchedTasks.get(taskId), + resume: async (args: ResumeCall) => { + resumeCalls.push(args) + return { id: `resume-${resumeCalls.length}` } + }, + } as unknown as BackgroundManager + + return { manager, launchCalls, resumeCalls } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: ToolContextLike, +): Promise { + const output = await tools[toolName].execute(args, context as any) + return JSON.parse(output) +} + +describe("agent-teams delegation consistency", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("team delegation forwards parent context like normal delegate-task", async () => { + //#given + const { manager, launchCalls, resumeCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext: ToolContextLike = { + sessionID: "ses-main", + messageID: "msg-main", + abort: new AbortController().signal, + agent: "sisyphus", + } + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) as { error?: string } + + //#then + expect(spawnResult.error).toBeUndefined() + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].parentAgent).toBe("sisyphus") + expect("parentModel" in launchCalls[0]).toBe(true) + + //#when + const messageResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(messageResult.error).toBeUndefined() + expect(resumeCalls).toHaveLength(1) + expect(resumeCalls[0].parentAgent).toBe("sisyphus") + expect("parentModel" in resumeCalls[0]).toBe(true) + }) + + test("send_message accepts teammate agent_id as recipient", async () => { + //#given + const { manager, resumeCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext: ToolContextLike = { + sessionID: "ses-main", + messageID: "msg-main", + abort: new AbortController().signal, + } + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + //#when + const messageResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1@core", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(messageResult.error).toBeUndefined() + expect(resumeCalls).toHaveLength(1) + }) +}) diff --git a/src/tools/agent-teams/dependency-writer.ts b/src/tools/agent-teams/dependency-writer.ts new file mode 100644 index 0000000000..37591f3be7 --- /dev/null +++ b/src/tools/agent-teams/dependency-writer.ts @@ -0,0 +1,97 @@ +import { getTeamTaskPath } from "./paths" +import type { TeamTask } from "./types" + +type TaskReader = (taskId: string) => TeamTask | null + +interface DependencyWriteParams { + teamName: string + taskId: string + task: TeamTask + pendingWrites: Map + readTask: TaskReader +} + +export function applyAddedBlocks(params: DependencyWriteParams, addBlocks: string[]): void { + const { teamName, taskId, task, pendingWrites, readTask } = params + const existingBlocks = new Set(task.blocks) + + for (const blockedTaskId of addBlocks) { + if (!existingBlocks.has(blockedTaskId)) { + task.blocks.push(blockedTaskId) + existingBlocks.add(blockedTaskId) + } + + const otherPath = getTeamTaskPath(teamName, blockedTaskId) + const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId) + if (other && !other.blockedBy.includes(taskId)) { + pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] }) + } + } +} + +export function applyAddedBlockedBy(params: DependencyWriteParams, addBlockedBy: string[]): void { + const { teamName, taskId, task, pendingWrites, readTask } = params + const existingBlockedBy = new Set(task.blockedBy) + + for (const blockerId of addBlockedBy) { + if (!existingBlockedBy.has(blockerId)) { + task.blockedBy.push(blockerId) + existingBlockedBy.add(blockerId) + } + + const otherPath = getTeamTaskPath(teamName, blockerId) + const other = pendingWrites.get(otherPath) ?? readTask(blockerId) + if (other && !other.blocks.includes(taskId)) { + pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] }) + } + } +} + +export function removeCompletedTaskFromDependents( + teamName: string, + taskId: string, + allTaskIds: string[], + pendingWrites: Map, + readTask: TaskReader, +): void { + for (const otherId of allTaskIds) { + if (otherId === taskId) { + continue + } + + const otherPath = getTeamTaskPath(teamName, otherId) + const other = pendingWrites.get(otherPath) ?? readTask(otherId) + if (other?.blockedBy.includes(taskId)) { + pendingWrites.set(otherPath, { + ...other, + blockedBy: other.blockedBy.filter((id) => id !== taskId), + }) + } + } +} + +export function removeDeletedTaskReferences( + teamName: string, + taskId: string, + allTaskIds: string[], + pendingWrites: Map, + readTask: TaskReader, +): void { + for (const otherId of allTaskIds) { + if (otherId === taskId) { + continue + } + + const otherPath = getTeamTaskPath(teamName, otherId) + const other = pendingWrites.get(otherPath) ?? readTask(otherId) + if (!other) { + continue + } + + pendingWrites.set(otherPath, { + ...other, + blockedBy: other.blockedBy.filter((id) => id !== taskId), + blocks: other.blocks.filter((id) => id !== taskId), + }) + } +} diff --git a/src/tools/agent-teams/inbox-store.test.ts b/src/tools/agent-teams/inbox-store.test.ts new file mode 100644 index 0000000000..9a4195004d --- /dev/null +++ b/src/tools/agent-teams/inbox-store.test.ts @@ -0,0 +1,59 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store" +import { getTeamInboxPath } from "./paths" + +describe("agent-teams inbox store", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("readInbox fails on malformed inbox JSON without overwriting file", () => { + //#given + ensureInbox("core", "team-lead") + const inboxPath = getTeamInboxPath("core", "team-lead") + writeFileSync(inboxPath, "{", "utf-8") + + //#when + const readMalformedInbox = () => readInbox("core", "team-lead", false, false) + + //#then + expect(readMalformedInbox).toThrow("team_inbox_parse_failed") + expect(readFileSync(inboxPath, "utf-8")).toBe("{") + }) + + test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => { + //#given + ensureInbox("core", "team-lead") + const inboxPath = getTeamInboxPath("core", "team-lead") + writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8") + + //#when + const appendIntoInvalidInbox = () => { + appendInboxMessage("core", "team-lead", { + from: "team-lead", + text: "hello", + timestamp: new Date().toISOString(), + read: false, + summary: "note", + }) + } + + //#then + expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid") + expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true })) + }) +}) diff --git a/src/tools/agent-teams/inbox-store.ts b/src/tools/agent-teams/inbox-store.ts new file mode 100644 index 0000000000..3a6b10199d --- /dev/null +++ b/src/tools/agent-teams/inbox-store.ts @@ -0,0 +1,191 @@ +import { existsSync, readFileSync, unlinkSync } from "node:fs" +import { z } from "zod" +import { acquireLock, ensureDir, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { getTeamInboxDir, getTeamInboxPath } from "./paths" +import { validateAgentNameOrLead, validateTeamName } from "./name-validation" +import { TeamInboxMessage, TeamInboxMessageSchema } from "./types" + +const TeamInboxListSchema = z.array(TeamInboxMessageSchema) + +function nowIso(): string { + return new Date().toISOString() +} + +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function assertValidInboxAgentName(agentName: string): void { + const validationError = validateAgentNameOrLead(agentName) + if (validationError) { + throw new Error(validationError) + } +} + +function withInboxLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) + const inboxDir = getTeamInboxDir(teamName) + ensureDir(inboxDir) + const lock = acquireLock(inboxDir) + if (!lock.acquired) { + throw new Error("inbox_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +function parseInboxFile(content: string): TeamInboxMessage[] { + let parsed: unknown + + try { + parsed = JSON.parse(content) + } catch { + throw new Error("team_inbox_parse_failed") + } + + const result = TeamInboxListSchema.safeParse(parsed) + if (!result.success) { + throw new Error("team_inbox_schema_invalid") + } + + return result.data +} + +function readInboxMessages(teamName: string, agentName: string): TeamInboxMessage[] { + assertValidTeamName(teamName) + assertValidInboxAgentName(agentName) + const path = getTeamInboxPath(teamName, agentName) + if (!existsSync(path)) { + return [] + } + return parseInboxFile(readFileSync(path, "utf-8")) +} + +function writeInboxMessages(teamName: string, agentName: string, messages: TeamInboxMessage[]): void { + assertValidTeamName(teamName) + assertValidInboxAgentName(agentName) + const path = getTeamInboxPath(teamName, agentName) + writeJsonAtomic(path, messages) +} + +export function ensureInbox(teamName: string, agentName: string): void { + assertValidTeamName(teamName) + assertValidInboxAgentName(agentName) + withInboxLock(teamName, () => { + const path = getTeamInboxPath(teamName, agentName) + if (!existsSync(path)) { + writeJsonAtomic(path, []) + } + }) +} + +export function appendInboxMessage(teamName: string, agentName: string, message: TeamInboxMessage): void { + assertValidTeamName(teamName) + assertValidInboxAgentName(agentName) + withInboxLock(teamName, () => { + const path = getTeamInboxPath(teamName, agentName) + const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : [] + messages.push(TeamInboxMessageSchema.parse(message)) + writeInboxMessages(teamName, agentName, messages) + }) +} + +export function clearInbox(teamName: string, agentName: string): void { + assertValidTeamName(teamName) + assertValidInboxAgentName(agentName) + withInboxLock(teamName, () => { + const path = getTeamInboxPath(teamName, agentName) + if (existsSync(path)) { + unlinkSync(path) + } + }) +} + +export function sendPlainInboxMessage( + teamName: string, + from: string, + to: string, + text: string, + summary: string, + color?: string, +): void { + appendInboxMessage(teamName, to, { + from, + text, + timestamp: nowIso(), + read: false, + summary, + ...(color ? { color } : {}), + }) +} + +export function sendStructuredInboxMessage( + teamName: string, + from: string, + to: string, + payload: Record, + summary?: string, +): void { + appendInboxMessage(teamName, to, { + from, + text: JSON.stringify(payload), + timestamp: nowIso(), + read: false, + ...(summary ? { summary } : {}), + }) +} + +export function readInbox( + teamName: string, + agentName: string, + unreadOnly = false, + markAsRead = true, +): TeamInboxMessage[] { + return withInboxLock(teamName, () => { + const messages = readInboxMessages(teamName, agentName) + + const selectedIndexes = new Set() + const selected = unreadOnly + ? messages.filter((message, index) => { + if (!message.read) { + selectedIndexes.add(index) + return true + } + return false + }) + : messages.map((message, index) => { + selectedIndexes.add(index) + return message + }) + + if (!markAsRead || selected.length === 0) { + return selected + } + + let changed = false + + const updated = messages.map((message, index) => { + if (selectedIndexes.has(index) && !message.read) { + changed = true + return { ...message, read: true } + } + return message + }) + + if (changed) { + writeInboxMessages(teamName, agentName, updated) + } + return updated.filter((_, index) => selectedIndexes.has(index)) + }) +} + +export function buildShutdownRequestId(recipient: string): string { + return `shutdown-${Date.now()}@${recipient}` +} diff --git a/src/tools/agent-teams/index.ts b/src/tools/agent-teams/index.ts new file mode 100644 index 0000000000..2b66c3710f --- /dev/null +++ b/src/tools/agent-teams/index.ts @@ -0,0 +1 @@ +export { createAgentTeamsTools } from "./tools" diff --git a/src/tools/agent-teams/message-tool-context.ts b/src/tools/agent-teams/message-tool-context.ts new file mode 100644 index 0000000000..9f97e47f79 --- /dev/null +++ b/src/tools/agent-teams/message-tool-context.ts @@ -0,0 +1,37 @@ +import { isTeammateMember } from "./team-member-utils" +import type { TeamConfig, TeamToolContext } from "./types" + +export function nowIso(): string { + return new Date().toISOString() +} + +export function validateRecipientTeam(recipient: unknown, teamName: string): string | null { + if (typeof recipient !== "string") { + return null + } + + const trimmed = recipient.trim() + const atIndex = trimmed.indexOf("@") + if (atIndex <= 0) { + return null + } + + const specifiedTeam = trimmed.slice(atIndex + 1).trim() + if (!specifiedTeam) { + return "recipient_team_invalid" + } + if (specifiedTeam === teamName) { + return null + } + + return "recipient_team_mismatch" +} + +export function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): string | null { + if (context.sessionID === config.leadSessionId) { + return "team-lead" + } + + const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID) + return matchedMember?.name ?? null +} diff --git a/src/tools/agent-teams/messaging-tools.test.ts b/src/tools/agent-teams/messaging-tools.test.ts new file mode 100644 index 0000000000..18c2ccd0f8 --- /dev/null +++ b/src/tools/agent-teams/messaging-tools.test.ts @@ -0,0 +1,211 @@ +/// +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 type { BackgroundManager } from "../../features/background-agent" +import { createAgentTeamsTools } from "./tools" + +interface TestToolContext { + sessionID: string + messageID: string + agent: string + abort: AbortSignal +} + +interface ResumeCall { + sessionId: string + prompt: string +} + +function createContext(sessionID = "ses-main"): TestToolContext { + return { + sessionID, + messageID: "msg-main", + agent: "sisyphus", + abort: new AbortController().signal, + } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: TestToolContext, +): Promise { + const output = await tools[toolName].execute(args, context) + return JSON.parse(output) +} + +function createManagerWithImmediateResume(): { manager: BackgroundManager; resumeCalls: ResumeCall[] } { + const resumeCalls: ResumeCall[] = [] + let launchCount = 0 + + const manager = { + launch: async () => { + launchCount += 1 + return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` } + }, + getTask: () => undefined, + resume: async (args: ResumeCall) => { + resumeCalls.push(args) + return { id: `resume-${resumeCalls.length}` } + }, + } as unknown as BackgroundManager + + return { manager, resumeCalls } +} + +function createManagerWithDeferredResume(): { + manager: BackgroundManager + resumeCalls: ResumeCall[] + resolveAllResumes: () => void +} { + const resumeCalls: ResumeCall[] = [] + const pendingResolves: Array<() => void> = [] + let launchCount = 0 + + const manager = { + launch: async () => { + launchCount += 1 + return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` } + }, + getTask: () => undefined, + resume: (args: ResumeCall) => { + resumeCalls.push(args) + return new Promise<{ id: string }>((resolve) => { + pendingResolves.push(() => resolve({ id: `resume-${resumeCalls.length}` })) + }) + }, + } as unknown as BackgroundManager + + return { + manager, + resumeCalls, + resolveAllResumes: () => { + while (pendingResolves.length > 0) { + const next = pendingResolves.shift() + next?.() + } + }, + } +} + +describe("agent-teams messaging tools", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-messaging-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("send_message rejects recipient team suffix mismatch", async () => { + //#given + const { manager, resumeCalls } = createManagerWithImmediateResume() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { team_name: "core", name: "worker_1", prompt: "Handle release prep", category: "quick" }, + leadContext, + ) + + //#when + const mismatchedRecipient = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1@other-team", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(mismatchedRecipient.error).toBe("recipient_team_mismatch") + expect(resumeCalls).toHaveLength(0) + }) + + test("send_message rejects recipient with empty team suffix", async () => { + //#given + const { manager, resumeCalls } = createManagerWithImmediateResume() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { team_name: "core", name: "worker_1", prompt: "Handle release prep", category: "quick" }, + leadContext, + ) + + //#when + const invalidRecipient = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1@", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(invalidRecipient.error).toBe("recipient_team_invalid") + expect(resumeCalls).toHaveLength(0) + }) + + test("broadcast schedules teammate resumes without serial await", async () => { + //#given + const { manager, resumeCalls, resolveAllResumes } = createManagerWithDeferredResume() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + for (const name of ["worker_1", "worker_2", "worker_3"]) { + await executeJsonTool( + tools, + "spawn_teammate", + { team_name: "core", name, prompt: "Handle release prep", category: "quick" }, + leadContext, + ) + } + + //#when + const broadcastPromise = executeJsonTool( + tools, + "send_message", + { team_name: "core", type: "broadcast", summary: "sync", content: "Please update status." }, + leadContext, + ) as Promise<{ success?: boolean; message?: string }> + + await Promise.resolve() + await Promise.resolve() + + //#then + expect(resumeCalls).toHaveLength(3) + + //#when + resolveAllResumes() + const broadcastResult = await broadcastPromise + + //#then + expect(broadcastResult.success).toBe(true) + expect(broadcastResult.message).toBe("broadcast_sent:3") + }) +}) diff --git a/src/tools/agent-teams/messaging-tools.ts b/src/tools/agent-teams/messaging-tools.ts new file mode 100644 index 0000000000..ce84602e8c --- /dev/null +++ b/src/tools/agent-teams/messaging-tools.ts @@ -0,0 +1,2 @@ +export { createSendMessageTool } from "./send-message-tool" +export { createReadInboxTool } from "./read-inbox-tool" diff --git a/src/tools/agent-teams/name-validation.test.ts b/src/tools/agent-teams/name-validation.test.ts new file mode 100644 index 0000000000..ec68b0e94e --- /dev/null +++ b/src/tools/agent-teams/name-validation.test.ts @@ -0,0 +1,79 @@ +/// +import { describe, expect, test } from "bun:test" +import { + validateAgentName, + validateAgentNameOrLead, + validateTaskId, + validateTeamName, +} from "./name-validation" + +describe("agent-teams name validation", () => { + test("accepts valid team names", () => { + //#given + const validNames = ["team_1", "alpha-team", "A1"] + + //#when + const result = validNames.map(validateTeamName) + + //#then + expect(result).toEqual([null, null, null]) + }) + + test("rejects invalid and empty team names", () => { + //#given + const blank = "" + const invalid = "team space" + const tooLong = "a".repeat(65) + + //#when + const blankResult = validateTeamName(blank) + const invalidResult = validateTeamName(invalid) + const tooLongResult = validateTeamName(tooLong) + + //#then + expect(blankResult).toBe("team_name_required") + expect(invalidResult).toBe("team_name_invalid") + expect(tooLongResult).toBe("team_name_too_long") + }) + + test("rejects reserved teammate name", () => { + //#given + const reservedName = "team-lead" + + //#when + const result = validateAgentName(reservedName) + + //#then + expect(result).toBe("agent_name_reserved") + }) + + test("validates regular agent names", () => { + //#given + const valid = "worker_1" + const invalid = "worker one" + + //#when + const validResult = validateAgentName(valid) + const invalidResult = validateAgentName(invalid) + + //#then + expect(validResult).toBeNull() + expect(invalidResult).toBe("agent_name_invalid") + }) + + test("allows team-lead for inbox-compatible validation", () => { + //#then + expect(validateAgentNameOrLead("team-lead")).toBeNull() + expect(validateAgentNameOrLead("worker_1")).toBeNull() + expect(validateAgentNameOrLead("worker one")).toBe("agent_name_invalid") + }) + + test("validates task ids", () => { + //#then + expect(validateTaskId("T-123")).toBeNull() + expect(validateTaskId("123")).toBe("task_id_invalid") + expect(validateTaskId("")).toBe("task_id_required") + expect(validateTaskId("../../etc/passwd")).toBe("task_id_invalid") + expect(validateTaskId(`T-${"a".repeat(127)}`)).toBe("task_id_too_long") + }) +}) diff --git a/src/tools/agent-teams/name-validation.ts b/src/tools/agent-teams/name-validation.ts new file mode 100644 index 0000000000..83e8784ea4 --- /dev/null +++ b/src/tools/agent-teams/name-validation.ts @@ -0,0 +1,54 @@ +const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/ +const MAX_NAME_LENGTH = 64 +const VALID_TASK_ID_RE = /^T-[A-Za-z0-9_-]+$/ +const MAX_TASK_ID_LENGTH = 128 + +function validateName(value: string, label: "team" | "agent"): string | null { + if (!value || !value.trim()) { + return `${label}_name_required` + } + + if (!VALID_NAME_RE.test(value)) { + return `${label}_name_invalid` + } + + if (value.length > MAX_NAME_LENGTH) { + return `${label}_name_too_long` + } + + return null +} + +export function validateTeamName(teamName: string): string | null { + return validateName(teamName, "team") +} + +export function validateAgentName(agentName: string): string | null { + if (agentName === "team-lead") { + return "agent_name_reserved" + } + return validateName(agentName, "agent") +} + +export function validateAgentNameOrLead(agentName: string): string | null { + if (agentName === "team-lead") { + return null + } + return validateName(agentName, "agent") +} + +export function validateTaskId(taskId: string): string | null { + if (!taskId || !taskId.trim()) { + return "task_id_required" + } + + if (!VALID_TASK_ID_RE.test(taskId)) { + return "task_id_invalid" + } + + if (taskId.length > MAX_TASK_ID_LENGTH) { + return "task_id_too_long" + } + + return null +} diff --git a/src/tools/agent-teams/paths.test.ts b/src/tools/agent-teams/paths.test.ts new file mode 100644 index 0000000000..ab5de9a1b4 --- /dev/null +++ b/src/tools/agent-teams/paths.test.ts @@ -0,0 +1,80 @@ +/// +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 { + getAgentTeamsRootDir, + getTeamConfigPath, + getTeamDir, + getTeamInboxDir, + getTeamInboxPath, + getTeamTaskDir, + getTeamTaskPath, + getTeamsRootDir, + getTeamTasksRootDir, +} from "./paths" + +describe("agent-teams paths", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-paths-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("uses project-local .sisyphus directory as storage root", () => { + //#given + const expectedRoot = join(tempProjectDir, ".sisyphus", "agent-teams") + + //#when + const root = getAgentTeamsRootDir() + + //#then + expect(root).toBe(expectedRoot) + }) + + test("builds expected teams and tasks root directories", () => { + //#given + const expectedRoot = join(tempProjectDir, ".sisyphus", "agent-teams") + + //#when + const teamsRoot = getTeamsRootDir() + const tasksRoot = getTeamTasksRootDir() + + //#then + expect(teamsRoot).toBe(join(expectedRoot, "teams")) + expect(tasksRoot).toBe(join(expectedRoot, "tasks")) + }) + + test("builds team-scoped config, inbox, and task file paths", () => { + //#given + const teamName = "alpha_team" + const agentName = "worker_1" + const taskId = "T-123" + const expectedTeamDir = join(getTeamsRootDir(), teamName) + + //#when + const teamDir = getTeamDir(teamName) + const configPath = getTeamConfigPath(teamName) + const inboxDir = getTeamInboxDir(teamName) + const inboxPath = getTeamInboxPath(teamName, agentName) + const taskDir = getTeamTaskDir(teamName) + const taskPath = getTeamTaskPath(teamName, taskId) + + //#then + expect(teamDir).toBe(expectedTeamDir) + expect(configPath).toBe(join(expectedTeamDir, "config.json")) + expect(inboxDir).toBe(join(expectedTeamDir, "inboxes")) + expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`)) + expect(taskDir).toBe(join(getTeamTasksRootDir(), teamName)) + expect(taskPath).toBe(join(getTeamTasksRootDir(), teamName, `${taskId}.json`)) + }) +}) diff --git a/src/tools/agent-teams/paths.ts b/src/tools/agent-teams/paths.ts new file mode 100644 index 0000000000..064a41c158 --- /dev/null +++ b/src/tools/agent-teams/paths.ts @@ -0,0 +1,40 @@ +import { join } from "node:path" + +const SISYPHUS_DIR = ".sisyphus" +const AGENT_TEAMS_DIR = "agent-teams" + +export function getAgentTeamsRootDir(): string { + return join(process.cwd(), SISYPHUS_DIR, AGENT_TEAMS_DIR) +} + +export function getTeamsRootDir(): string { + return join(getAgentTeamsRootDir(), "teams") +} + +export function getTeamTasksRootDir(): string { + return join(getAgentTeamsRootDir(), "tasks") +} + +export function getTeamDir(teamName: string): string { + return join(getTeamsRootDir(), teamName) +} + +export function getTeamConfigPath(teamName: string): string { + return join(getTeamDir(teamName), "config.json") +} + +export function getTeamInboxDir(teamName: string): string { + return join(getTeamDir(teamName), "inboxes") +} + +export function getTeamInboxPath(teamName: string, agentName: string): string { + return join(getTeamInboxDir(teamName), `${agentName}.json`) +} + +export function getTeamTaskDir(teamName: string): string { + return join(getTeamTasksRootDir(), teamName) +} + +export function getTeamTaskPath(teamName: string, taskId: string): string { + return join(getTeamTaskDir(teamName), `${taskId}.json`) +} diff --git a/src/tools/agent-teams/read-inbox-tool.ts b/src/tools/agent-teams/read-inbox-tool.ts new file mode 100644 index 0000000000..cd6510345f --- /dev/null +++ b/src/tools/agent-teams/read-inbox-tool.ts @@ -0,0 +1,50 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { readInbox } from "./inbox-store" +import { resolveSenderFromContext } from "./message-tool-context" +import { readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentNameOrLead, validateTeamName } from "./name-validation" +import { TeamReadInboxInputSchema, TeamToolContext } 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, context: TeamToolContext): Promise => { + try { + const input = TeamReadInboxInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const agentError = validateAgentNameOrLead(input.agent_name) + if (agentError) { + return JSON.stringify({ error: agentError }) + } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveSenderFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_reader_session" }) + } + + if (actor !== "team-lead" && actor !== input.agent_name) { + return JSON.stringify({ error: "unauthorized_reader_session" }) + } + + const messages = readInbox( + input.team_name, + input.agent_name, + input.unread_only ?? false, + input.mark_as_read ?? true, + ) + return JSON.stringify(messages) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/send-message-tool.ts b/src/tools/agent-teams/send-message-tool.ts new file mode 100644 index 0000000000..913bcdc5f5 --- /dev/null +++ b/src/tools/agent-teams/send-message-tool.ts @@ -0,0 +1,197 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { BackgroundManager } from "../../features/background-agent" +import { buildShutdownRequestId, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store" +import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentNameOrLead, validateTeamName } from "./name-validation" +import { resumeTeammateWithMessage } from "./teammate-runtime" +import { isTeammateMember } from "./team-member-utils" +import { nowIso, resolveSenderFromContext, validateRecipientTeam } from "./message-tool-context" +import { TeamSendMessageInputSchema, TeamToolContext } from "./types" + +export function createSendMessageTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Send direct or broadcast team messages and protocol responses.", + args: { + team_name: tool.schema.string().describe("Team name"), + type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]), + recipient: tool.schema.string().optional().describe("Message recipient"), + content: tool.schema.string().optional().describe("Message body"), + summary: tool.schema.string().optional().describe("Short summary"), + request_id: tool.schema.string().optional().describe("Protocol request id"), + approve: tool.schema.boolean().optional().describe("Approval flag"), + sender: tool.schema + .string() + .optional() + .describe("Sender name inferred from calling session; explicit value must match resolved sender."), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamSendMessageInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const recipientTeamError = validateRecipientTeam(args.recipient, input.team_name) + if (recipientTeamError) { + return JSON.stringify({ error: recipientTeamError }) + } + const requestedSender = input.sender + const senderError = requestedSender ? validateAgentNameOrLead(requestedSender) : null + if (senderError) { + return JSON.stringify({ error: senderError }) + } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveSenderFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_sender_session" }) + } + if (requestedSender && requestedSender !== actor) { + return JSON.stringify({ error: "sender_context_mismatch" }) + } + const sender = requestedSender ?? actor + + const memberNames = new Set(config.members.map((member) => member.name)) + if (sender !== "team-lead" && !memberNames.has(sender)) { + return JSON.stringify({ error: "invalid_sender" }) + } + + if (input.type === "message") { + if (!input.recipient || !input.summary || !input.content) { + return JSON.stringify({ error: "message_requires_recipient_summary_content" }) + } + if (!memberNames.has(input.recipient)) { + return JSON.stringify({ error: "message_recipient_not_found" }) + } + + const targetMember = getTeamMember(config, input.recipient) + const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined + sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color) + + if (targetMember && isTeammateMember(targetMember)) { + await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content) + } + + return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` }) + } + + if (input.type === "broadcast") { + if (!input.summary) { + return JSON.stringify({ error: "broadcast_requires_summary" }) + } + const broadcastSummary = input.summary + const teammates = listTeammates(config) + for (const teammate of teammates) { + sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", broadcastSummary) + } + await Promise.allSettled( + teammates.map((teammate) => + resumeTeammateWithMessage(manager, context, input.team_name, teammate, broadcastSummary, input.content ?? ""), + ), + ) + return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` }) + } + + if (input.type === "shutdown_request") { + if (!input.recipient) { + return JSON.stringify({ error: "shutdown_request_requires_recipient" }) + } + if (input.recipient === "team-lead") { + return JSON.stringify({ error: "cannot_shutdown_team_lead" }) + } + const targetMember = getTeamMember(config, input.recipient) + if (!targetMember || !isTeammateMember(targetMember)) { + return JSON.stringify({ error: "shutdown_recipient_not_found" }) + } + + const requestId = buildShutdownRequestId(input.recipient) + sendStructuredInboxMessage( + input.team_name, + sender, + input.recipient, + { + type: "shutdown_request", + requestId, + from: sender, + reason: input.content ?? "", + timestamp: nowIso(), + }, + "shutdown_request", + ) + + await resumeTeammateWithMessage( + manager, + context, + input.team_name, + targetMember, + "shutdown_request", + input.content ?? "Shutdown requested", + ) + + return JSON.stringify({ success: true, request_id: requestId, target: input.recipient }) + } + + if (input.type === "shutdown_response") { + if (!input.request_id) { + return JSON.stringify({ error: "shutdown_response_requires_request_id" }) + } + if (input.approve) { + sendStructuredInboxMessage( + input.team_name, + sender, + "team-lead", + { + type: "shutdown_approved", + requestId: input.request_id, + from: sender, + timestamp: nowIso(), + backendType: "native", + }, + "shutdown_approved", + ) + return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` }) + } + + sendPlainInboxMessage( + input.team_name, + sender, + "team-lead", + input.content ?? "Shutdown rejected", + "shutdown_rejected", + ) + return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` }) + } + + if (!input.recipient) { + return JSON.stringify({ error: "plan_response_requires_recipient" }) + } + if (!memberNames.has(input.recipient)) { + return JSON.stringify({ error: "plan_response_recipient_not_found" }) + } + + const targetMember = getTeamMember(config, input.recipient) + + if (input.approve) { + sendStructuredInboxMessage( + input.team_name, + sender, + input.recipient, + { type: "plan_approval", approved: true, requestId: input.request_id }, + "plan_approved", + ) + if (targetMember && isTeammateMember(targetMember)) { + await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, "plan_approved", input.content ?? "Plan approved") + } + return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` }) + } + + sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content ?? "Plan rejected", "plan_rejected") + if (targetMember && isTeammateMember(targetMember)) { + await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, "plan_rejected", input.content ?? "Plan rejected") + } + return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/team-config-store.test.ts b/src/tools/agent-teams/team-config-store.test.ts new file mode 100644 index 0000000000..b908a9e0e9 --- /dev/null +++ b/src/tools/agent-teams/team-config-store.test.ts @@ -0,0 +1,141 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { acquireLock } from "../../features/claude-tasks/storage" +import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths" +import { + createTeamConfig, + deleteTeamData, + readTeamConfigOrThrow, + teamExists, + upsertTeammate, + writeTeamConfig, +} from "./team-config-store" + +describe("agent-teams team config store", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-")) + process.chdir(tempProjectDir) + createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") + }) + + afterEach(() => { + if (teamExists("core")) { + deleteTeamData("core") + } + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("deleteTeamData waits for team lock before removing team files", () => { + //#given + const lock = acquireLock(getTeamDir("core")) + expect(lock.acquired).toBe(true) + + try { + //#when + const deleteWhileLocked = () => deleteTeamData("core") + + //#then + expect(deleteWhileLocked).toThrow("team_lock_unavailable") + expect(teamExists("core")).toBe(true) + } finally { + //#when + lock.release() + } + + deleteTeamData("core") + + //#then + expect(teamExists("core")).toBe(false) + }) + + test("deleteTeamData waits for task lock before removing task files", () => { + //#given + const lock = acquireLock(getTeamTaskDir("core")) + expect(lock.acquired).toBe(true) + + try { + //#when + const deleteWhileLocked = () => deleteTeamData("core") + + //#then + expect(deleteWhileLocked).toThrow("team_task_lock_unavailable") + expect(teamExists("core")).toBe(true) + } finally { + lock.release() + } + + //#when + deleteTeamData("core") + + //#then + expect(teamExists("core")).toBe(false) + }) + + test("deleteTeamData removes task files before deleting team directory", () => { + //#given + const taskDir = getTeamTaskDir("core") + const teamDir = getTeamDir("core") + const teamsRootDir = getTeamsRootDir() + expect(existsSync(taskDir)).toBe(true) + expect(existsSync(teamDir)).toBe(true) + + //#when + chmodSync(teamsRootDir, 0o555) + try { + const deleteWithBlockedTeamParent = () => deleteTeamData("core") + expect(deleteWithBlockedTeamParent).toThrow() + } finally { + chmodSync(teamsRootDir, 0o755) + } + + //#then + expect(existsSync(taskDir)).toBe(false) + expect(existsSync(teamDir)).toBe(true) + }) + + test("deleteTeamData fails if team has active teammates", () => { + //#given + const config = readTeamConfigOrThrow("core") + const updated = upsertTeammate(config, { + agentId: "teammate@core", + name: "teammate", + agentType: "sisyphus", + category: "test", + model: "sisyphus", + prompt: "test prompt", + color: "#000000", + planModeRequired: false, + joinedAt: Date.now(), + cwd: process.cwd(), + subscriptions: [], + backendType: "native", + isActive: true, + sessionID: "ses-sub", + }) + writeTeamConfig("core", updated) + + //#when + const deleteWithTeammates = () => deleteTeamData("core") + + //#then + expect(deleteWithTeammates).toThrow("team_has_active_members") + expect(teamExists("core")).toBe(true) + + //#when - cleanup teammate to allow afterEach to succeed + const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") } + writeTeamConfig("core", cleared) + deleteTeamData("core") + + //#then + expect(teamExists("core")).toBe(false) + }) + +}) diff --git a/src/tools/agent-teams/team-config-store.ts b/src/tools/agent-teams/team-config-store.ts new file mode 100644 index 0000000000..7db905759a --- /dev/null +++ b/src/tools/agent-teams/team-config-store.ts @@ -0,0 +1,195 @@ +import { existsSync, rmSync } from "node:fs" +import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { + getTeamConfigPath, + getTeamDir, + getTeamInboxDir, + getTeamTaskDir, + getTeamTasksRootDir, + getTeamsRootDir, +} from "./paths" +import { + TEAM_COLOR_PALETTE, + TeamConfig, + TeamConfigSchema, + TeamLeadMember, + TeamMember, + TeamTeammateMember, +} from "./types" +import { isTeammateMember } from "./team-member-utils" +import { validateTeamName } from "./name-validation" +import { withTeamTaskLock } from "./team-task-store" + +function nowMs(): number { + return Date.now() +} + +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function withTeamLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) + const teamDir = getTeamDir(teamName) + ensureDir(teamDir) + const lock = acquireLock(teamDir) + if (!lock.acquired) { + throw new Error("team_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember { + return { + agentId: `team-lead@${teamName}`, + name: "team-lead", + agentType: "team-lead", + model: leadModel, + joinedAt: nowMs(), + cwd, + subscriptions: [], + } +} + +export function ensureTeamStorageDirs(teamName: string): void { + assertValidTeamName(teamName) + ensureDir(getTeamsRootDir()) + ensureDir(getTeamTasksRootDir()) + ensureDir(getTeamDir(teamName)) + ensureDir(getTeamInboxDir(teamName)) + ensureDir(getTeamTaskDir(teamName)) +} + +export function teamExists(teamName: string): boolean { + assertValidTeamName(teamName) + return existsSync(getTeamConfigPath(teamName)) +} + +export function createTeamConfig( + teamName: string, + description: string, + leadSessionId: string, + cwd: string, + leadModel: string, +): TeamConfig { + ensureTeamStorageDirs(teamName) + + const leadAgentId = `team-lead@${teamName}` + const config: TeamConfig = { + name: teamName, + description, + createdAt: nowMs(), + leadAgentId, + leadSessionId, + members: [createLeadMember(teamName, cwd, leadModel)], + } + + return withTeamLock(teamName, () => { + if (teamExists(teamName)) { + throw new Error("team_already_exists") + } + writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config)) + return config + }) +} + +export function readTeamConfig(teamName: string): TeamConfig | null { + assertValidTeamName(teamName) + return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema) +} + +export function readTeamConfigOrThrow(teamName: string): TeamConfig { + const config = readTeamConfig(teamName) + if (!config) { + throw new Error("team_not_found") + } + return config +} + +export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig { + assertValidTeamName(teamName) + return withTeamLock(teamName, () => { + const validated = TeamConfigSchema.parse(config) + writeJsonAtomic(getTeamConfigPath(teamName), validated) + return validated + }) +} + +export function updateTeamConfig(teamName: string, updater: (config: TeamConfig) => TeamConfig): TeamConfig { + assertValidTeamName(teamName) + return withTeamLock(teamName, () => { + const current = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema) + if (!current) { + throw new Error("team_not_found") + } + + const next = TeamConfigSchema.parse(updater(current)) + writeJsonAtomic(getTeamConfigPath(teamName), next) + return next + }) +} + +export function listTeammates(config: TeamConfig): TeamTeammateMember[] { + return config.members.filter(isTeammateMember) +} + +export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined { + return config.members.find((member) => member.name === name) +} + +export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig { + const members = config.members.filter((member) => member.name !== teammate.name) + members.push(teammate) + return { ...config, members } +} + +export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig { + if (agentName === "team-lead") { + throw new Error("cannot_remove_team_lead") + } + + return { + ...config, + members: config.members.filter((member) => member.name !== agentName), + } +} + +export function assignNextColor(config: TeamConfig): string { + const teammateCount = listTeammates(config).length + return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length] +} + +export function deleteTeamData(teamName: string): void { + assertValidTeamName(teamName) + withTeamLock(teamName, () => { + const config = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema) + if (!config) { + throw new Error("team_not_found") + } + + if (listTeammates(config).length > 0) { + throw new Error("team_has_active_members") + } + + withTeamTaskLock(teamName, () => { + const teamDir = getTeamDir(teamName) + const taskDir = getTeamTaskDir(teamName) + + if (existsSync(taskDir)) { + rmSync(taskDir, { recursive: true, force: true }) + } + + if (existsSync(teamDir)) { + rmSync(teamDir, { recursive: true, force: true }) + } + }) + }) +} diff --git a/src/tools/agent-teams/team-lifecycle-tools.test.ts b/src/tools/agent-teams/team-lifecycle-tools.test.ts new file mode 100644 index 0000000000..cffbb1e692 --- /dev/null +++ b/src/tools/agent-teams/team-lifecycle-tools.test.ts @@ -0,0 +1,69 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { BackgroundManager } from "../../features/background-agent" +import { getTeamDir } from "./paths" +import { createAgentTeamsTools } from "./tools" + +interface TestToolContext { + sessionID: string + messageID: string + agent: string + abort: AbortSignal +} + +function createContext(sessionID = "ses-main"): TestToolContext { + return { + sessionID, + messageID: "msg-main", + agent: "sisyphus", + abort: new AbortController().signal, + } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: TestToolContext, +): Promise { + const output = await tools[toolName].execute(args, context) + return JSON.parse(output) +} + +describe("agent-teams team lifecycle tools", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("team_delete requires lead session authorization", async () => { + //#given + const tools = createAgentTeamsTools({} as BackgroundManager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + //#when + const unauthorized = await executeJsonTool( + tools, + "team_delete", + { team_name: "core" }, + createContext("ses-intruder"), + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + expect(existsSync(getTeamDir("core"))).toBe(true) + }) +}) diff --git a/src/tools/agent-teams/team-lifecycle-tools.ts b/src/tools/agent-teams/team-lifecycle-tools.ts new file mode 100644 index 0000000000..2af6bd7b80 --- /dev/null +++ b/src/tools/agent-teams/team-lifecycle-tools.ts @@ -0,0 +1,133 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { getTeamConfigPath } from "./paths" +import { validateTeamName } from "./name-validation" +import { ensureInbox } from "./inbox-store" +import { + TeamConfig, + TeamCreateInputSchema, + TeamDeleteInputSchema, + TeamReadConfigInputSchema, + TeamToolContext, +} from "./types" +import { isTeammateMember } from "./team-member-utils" +import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store" + +function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null { + if (context.sessionID === config.leadSessionId) { + return "team-lead" + } + + const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID) + return matchedMember?.name ?? null +} + +function toPublicTeamConfig(config: TeamConfig): { + team_name: string + description: string + lead_agent_id: string + teammates: Array<{ name: string }> +} { + return { + team_name: config.name, + description: config.description, + lead_agent_id: config.leadAgentId, + teammates: listTeammates(config).map((member) => ({ name: member.name })), + } +} + +export function createTeamCreateTool(): ToolDefinition { + return tool({ + description: "Create a team workspace with config, inboxes, and task storage.", + args: { + team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"), + description: tool.schema.string().optional().describe("Team description"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamCreateInputSchema.parse(args) + const nameError = validateTeamName(input.team_name) + if (nameError) { + return JSON.stringify({ error: nameError }) + } + + const config = createTeamConfig( + input.team_name, + input.description ?? "", + context.sessionID, + process.cwd(), + "native/team-lead", + ) + ensureInbox(config.name, "team-lead") + + return JSON.stringify({ + team_name: config.name, + team_file_path: getTeamConfigPath(config.name), + lead_agent_id: config.leadAgentId, + }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_create_failed" }) + } + }, + }) +} + +export function createTeamDeleteTool(): ToolDefinition { + return tool({ + description: "Delete a team and its stored data. Fails if teammates still exist.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamDeleteInputSchema.parse(args) + const config = readTeamConfigOrThrow(input.team_name) + if (context.sessionID !== config.leadSessionId) { + return JSON.stringify({ error: "unauthorized_lead_session" }) + } + const teammates = listTeammates(config) + if (teammates.length > 0) { + return JSON.stringify({ + error: "team_has_active_members", + members: teammates.map((member) => member.name), + }) + } + + deleteTeamData(input.team_name) + return JSON.stringify({ success: true, team_name: input.team_name }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_delete_failed" }) + } + }, + }) +} + +export function createTeamReadConfigTool(): ToolDefinition { + return tool({ + description: "Read team configuration and member list.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamReadConfigInputSchema.parse(args) + const config = readTeamConfig(input.team_name) + if (!config) { + return JSON.stringify({ error: "team_not_found" }) + } + + const actor = resolveReaderFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_reader_session" }) + } + + if (actor !== "team-lead") { + return JSON.stringify(toPublicTeamConfig(config)) + } + + return JSON.stringify(config) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/team-member-utils.ts b/src/tools/agent-teams/team-member-utils.ts new file mode 100644 index 0000000000..4ea1ba3dd9 --- /dev/null +++ b/src/tools/agent-teams/team-member-utils.ts @@ -0,0 +1,5 @@ +import type { TeamMember, TeamTeammateMember } from "./types" + +export function isTeammateMember(member: TeamMember): member is TeamTeammateMember { + return member.agentType !== "team-lead" +} diff --git a/src/tools/agent-teams/team-recipient-utils.ts b/src/tools/agent-teams/team-recipient-utils.ts new file mode 100644 index 0000000000..8e707da90e --- /dev/null +++ b/src/tools/agent-teams/team-recipient-utils.ts @@ -0,0 +1,9 @@ +export function normalizeTeamRecipient(recipient: string): string { + const trimmed = recipient.trim() + const atIndex = trimmed.indexOf("@") + if (atIndex <= 0) { + return trimmed + } + + return trimmed.slice(0, atIndex) +} diff --git a/src/tools/agent-teams/team-task-dependency.test.ts b/src/tools/agent-teams/team-task-dependency.test.ts new file mode 100644 index 0000000000..0aea66d8a9 --- /dev/null +++ b/src/tools/agent-teams/team-task-dependency.test.ts @@ -0,0 +1,94 @@ +/// +import { describe, expect, test } from "bun:test" +import { + addPendingEdge, + createPendingEdgeMap, + ensureDependenciesCompleted, + ensureForwardStatusTransition, + wouldCreateCycle, +} from "./team-task-dependency" +import type { TeamTask, TeamTaskStatus } from "./types" + +function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask { + return { + id, + subject: `Task ${id}`, + description: `Description ${id}`, + status, + blocks: [], + blockedBy, + } +} + +describe("agent-teams task dependency utilities", () => { + test("detects cycle from existing blockedBy chain", () => { + //#given + const tasks = new Map([ + ["A", createTask("A", "pending", ["B"])], + ["B", createTask("B", "pending")], + ]) + const pending = createPendingEdgeMap() + const readTask = (id: string) => tasks.get(id) ?? null + + //#when + const hasCycle = wouldCreateCycle("B", "A", pending, readTask) + + //#then + expect(hasCycle).toBe(true) + }) + + test("detects cycle from pending edge map", () => { + //#given + const tasks = new Map([["A", createTask("A", "pending")]]) + const pending = createPendingEdgeMap() + addPendingEdge(pending, "A", "B") + const readTask = (id: string) => tasks.get(id) ?? null + + //#when + const hasCycle = wouldCreateCycle("B", "A", pending, readTask) + + //#then + expect(hasCycle).toBe(true) + }) + + test("returns false when dependency graph has no cycle", () => { + //#given + const tasks = new Map([ + ["A", createTask("A", "pending")], + ["B", createTask("B", "pending", ["A"])], + ]) + const pending = createPendingEdgeMap() + const readTask = (id: string) => tasks.get(id) ?? null + + //#when + const hasCycle = wouldCreateCycle("C", "B", pending, readTask) + + //#then + expect(hasCycle).toBe(false) + }) + + test("allows forward status transitions and blocks backward transitions", () => { + //#then + expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow() + expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow() + expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow( + "invalid_status_transition:in_progress->pending", + ) + }) + + test("requires blockers to be completed for in_progress/completed", () => { + //#given + const tasks = new Map([ + ["done", createTask("done", "completed")], + ["wait", createTask("wait", "pending")], + ]) + const readTask = (id: string) => tasks.get(id) ?? null + + //#then + expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow() + expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow() + expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow( + "blocked_by_incomplete:wait:pending", + ) + }) +}) diff --git a/src/tools/agent-teams/team-task-dependency.ts b/src/tools/agent-teams/team-task-dependency.ts new file mode 100644 index 0000000000..0edec7949c --- /dev/null +++ b/src/tools/agent-teams/team-task-dependency.ts @@ -0,0 +1,93 @@ +import type { TeamTask, TeamTaskStatus } from "./types" + +type PendingEdges = Record> + +export const TEAM_TASK_STATUS_ORDER: Record = { + pending: 0, + in_progress: 1, + completed: 2, + deleted: 3, +} + +export type TaskReader = (taskId: string) => TeamTask | null + +export function wouldCreateCycle( + fromTaskId: string, + toTaskId: string, + pendingEdges: PendingEdges, + readTask: TaskReader, +): boolean { + const visited = new Set() + const queue: string[] = [toTaskId] + + while (queue.length > 0) { + const current = queue.shift() + if (!current) { + continue + } + + if (current === fromTaskId) { + return true + } + + if (visited.has(current)) { + continue + } + visited.add(current) + + const task = readTask(current) + if (task) { + for (const dep of task.blockedBy) { + if (!visited.has(dep)) { + queue.push(dep) + } + } + } + + const pending = pendingEdges[current] + if (pending) { + for (const dep of pending) { + if (!visited.has(dep)) { + queue.push(dep) + } + } + } + } + + return false +} + +export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void { + const currentOrder = TEAM_TASK_STATUS_ORDER[current] + const nextOrder = TEAM_TASK_STATUS_ORDER[next] + if (nextOrder < currentOrder) { + throw new Error(`invalid_status_transition:${current}->${next}`) + } +} + +export function ensureDependenciesCompleted( + status: TeamTaskStatus, + blockedBy: string[], + readTask: TaskReader, +): void { + if (status !== "in_progress" && status !== "completed") { + return + } + + for (const blockerId of blockedBy) { + const blocker = readTask(blockerId) + if (blocker && blocker.status !== "completed") { + throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`) + } + } +} + +export function createPendingEdgeMap(): PendingEdges { + return {} +} + +export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void { + const existing = pendingEdges[from] ?? new Set() + existing.add(to) + pendingEdges[from] = existing +} diff --git a/src/tools/agent-teams/team-task-store.test.ts b/src/tools/agent-teams/team-task-store.test.ts new file mode 100644 index 0000000000..9314f3b949 --- /dev/null +++ b/src/tools/agent-teams/team-task-store.test.ts @@ -0,0 +1,44 @@ +/// +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 { createTeamConfig, deleteTeamData } from "./team-config-store" +import { createTeamTask, deleteTeamTaskFile, readTeamTask } from "./team-task-store" + +describe("agent-teams task store", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-task-store-")) + process.chdir(tempProjectDir) + createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") + }) + + afterEach(() => { + deleteTeamData("core") + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("creates and reads a task", () => { + //#given + const created = createTeamTask("core", "Subject", "Description") + + //#when + const loaded = readTeamTask("core", created.id) + + //#then + expect(loaded?.id).toBe(created.id) + expect(loaded?.subject).toBe("Subject") + }) + + test("rejects invalid team name and task id", () => { + //#then + expect(() => readTeamTask("../../etc", "T-1")).toThrow("team_name_invalid") + expect(() => readTeamTask("core", "../../passwd")).toThrow("task_id_invalid") + expect(() => deleteTeamTaskFile("core", "../../passwd")).toThrow("task_id_invalid") + }) +}) diff --git a/src/tools/agent-teams/team-task-store.ts b/src/tools/agent-teams/team-task-store.ts new file mode 100644 index 0000000000..6fb599b6d9 --- /dev/null +++ b/src/tools/agent-teams/team-task-store.ts @@ -0,0 +1,157 @@ +import { existsSync, readdirSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { + acquireLock, + ensureDir, + generateTaskId, + readJsonSafe, + writeJsonAtomic, +} from "../../features/claude-tasks/storage" +import { getTeamTaskDir, getTeamTaskPath } from "./paths" +import { TeamTask, TeamTaskSchema } from "./types" +import { validateTaskId, validateTeamName } from "./name-validation" + +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function assertValidTaskId(taskId: string): void { + const validationError = validateTaskId(taskId) + if (validationError) { + throw new Error(validationError) + } +} + +function withTaskLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) + const taskDir = getTeamTaskDir(teamName) + ensureDir(taskDir) + const lock = acquireLock(taskDir) + if (!lock.acquired) { + throw new Error("team_task_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +export function readTeamTask(teamName: string, taskId: string): TeamTask | null { + assertValidTeamName(teamName) + assertValidTaskId(taskId) + return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema) +} + +export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask { + const task = readTeamTask(teamName, taskId) + if (!task) { + throw new Error("team_task_not_found") + } + return task +} + +export function listTeamTasks(teamName: string): TeamTask[] { + assertValidTeamName(teamName) + const taskDir = getTeamTaskDir(teamName) + if (!existsSync(taskDir)) { + return [] + } + + const files = readdirSync(taskDir) + .filter((file) => file.endsWith(".json") && file.startsWith("T-")) + .sort((a, b) => a.localeCompare(b)) + + const tasks: TeamTask[] = [] + for (const file of files) { + const taskId = file.replace(/\.json$/, "") + if (validateTaskId(taskId)) { + continue + } + const task = readTeamTask(teamName, taskId) + if (task) { + tasks.push(task) + } + } + + return tasks +} + +export function createTeamTask( + teamName: string, + subject: string, + description: string, + activeForm?: string, + metadata?: Record, +): TeamTask { + assertValidTeamName(teamName) + if (!subject.trim()) { + throw new Error("team_task_subject_required") + } + + return withTaskLock(teamName, () => { + const task: TeamTask = { + id: generateTaskId(), + subject, + description, + activeForm, + status: "pending", + blocks: [], + blockedBy: [], + ...(metadata ? { metadata } : {}), + } + + const validated = TeamTaskSchema.parse(task) + writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) + return validated + }) +} + +export function writeTeamTask(teamName: string, task: TeamTask): TeamTask { + assertValidTeamName(teamName) + assertValidTaskId(task.id) + const validated = TeamTaskSchema.parse(task) + writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) + return validated +} + +export function deleteTeamTaskFile(teamName: string, taskId: string): void { + assertValidTeamName(teamName) + assertValidTaskId(taskId) + const taskPath = getTeamTaskPath(teamName, taskId) + if (existsSync(taskPath)) { + unlinkSync(taskPath) + } +} + +export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null { + assertValidTaskId(taskId) + return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema) +} + +export function resetOwnerTasks(teamName: string, ownerName: string): void { + assertValidTeamName(teamName) + withTaskLock(teamName, () => { + const tasks = listTeamTasks(teamName) + for (const task of tasks) { + if (task.owner !== ownerName) { + continue + } + const next: TeamTask = { + ...task, + owner: undefined, + status: task.status === "completed" ? "completed" : "pending", + } + writeTeamTask(teamName, next) + } + }) +} + +export function withTeamTaskLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) + return withTaskLock(teamName, operation) +} diff --git a/src/tools/agent-teams/team-task-tools.ts b/src/tools/agent-teams/team-task-tools.ts new file mode 100644 index 0000000000..9192346bdc --- /dev/null +++ b/src/tools/agent-teams/team-task-tools.ts @@ -0,0 +1,160 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { sendStructuredInboxMessage } from "./inbox-store" +import { readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation" +import { + TeamConfig, + TeamTaskCreateInputSchema, + TeamTaskGetInputSchema, + TeamTaskListInputSchema, + TeamTask, + TeamToolContext, +} from "./types" +import { isTeammateMember } from "./team-member-utils" +import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store" + +function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record { + return { + type: "task_assignment", + taskId: task.id, + subject: task.subject, + description: task.description, + assignedBy, + timestamp: new Date().toISOString(), + } +} + +export function resolveTaskActorFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null { + if (context.sessionID === config.leadSessionId) { + return "team-lead" + } + + const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID) + return matchedMember?.name ?? null +} + +export function createTeamTaskCreateTool(): ToolDefinition { + return tool({ + description: "Create a task in team-scoped storage.", + args: { + team_name: tool.schema.string().describe("Team name"), + subject: tool.schema.string().describe("Task subject"), + description: tool.schema.string().describe("Task description"), + active_form: tool.schema.string().optional().describe("Present-continuous form"), + metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamTaskCreateInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } + + const task = createTeamTask( + input.team_name, + input.subject, + input.description, + input.active_form, + input.metadata, + ) + + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" }) + } + }, + }) +} + +export function createTeamTaskListTool(): ToolDefinition { + return tool({ + description: "List tasks for one team.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamTaskListInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } + return JSON.stringify(listTeamTasks(input.team_name)) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" }) + } + }, + }) +} + +export function createTeamTaskGetTool(): ToolDefinition { + return tool({ + description: "Get one task from team-scoped storage.", + args: { + team_name: tool.schema.string().describe("Team name"), + task_id: tool.schema.string().describe("Task id"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamTaskGetInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const taskIdError = validateTaskId(input.task_id) + if (taskIdError) { + return JSON.stringify({ error: taskIdError }) + } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } + const task = readTeamTask(input.team_name, input.task_id) + if (!task) { + return JSON.stringify({ error: "team_task_not_found" }) + } + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" }) + } + }, + }) +} + +export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void { + if (!task.owner || task.status === "deleted") { + return + } + + if (validateTeamName(teamName)) { + return + } + + if (validateAgentNameOrLead(task.owner)) { + return + } + + if (validateAgentNameOrLead(assignedBy)) { + return + } + + sendStructuredInboxMessage( + teamName, + assignedBy, + task.owner, + buildTaskAssignmentPayload(task, assignedBy), + "task_assignment", + ) +} diff --git a/src/tools/agent-teams/team-task-update-tool.ts b/src/tools/agent-teams/team-task-update-tool.ts new file mode 100644 index 0000000000..2b4e34eb62 --- /dev/null +++ b/src/tools/agent-teams/team-task-update-tool.ts @@ -0,0 +1,91 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation" +import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types" +import { updateTeamTask } from "./team-task-update" +import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools" + +export function createTeamTaskUpdateTool(): ToolDefinition { + return tool({ + description: "Update task status, owner, dependencies, and metadata in a team task list.", + args: { + team_name: tool.schema.string().describe("Team name"), + task_id: tool.schema.string().describe("Task id"), + status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"), + owner: tool.schema.string().optional().describe("Task owner"), + subject: tool.schema.string().optional().describe("Task subject"), + description: tool.schema.string().optional().describe("Task description"), + active_form: tool.schema.string().optional().describe("Present-continuous form"), + add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"), + add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"), + metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamTaskUpdateInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const taskIdError = validateTaskId(input.task_id) + if (taskIdError) { + return JSON.stringify({ error: taskIdError }) + } + + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } + + const memberNames = new Set(config.members.map((member) => member.name)) + if (input.owner !== undefined) { + if (input.owner !== "") { + const ownerError = validateAgentNameOrLead(input.owner) + if (ownerError) { + return JSON.stringify({ error: ownerError }) + } + + if (!memberNames.has(input.owner)) { + return JSON.stringify({ error: "owner_not_in_team" }) + } + } + } + if (input.add_blocks) { + for (const blockerId of input.add_blocks) { + const blockerError = validateTaskId(blockerId) + if (blockerError) { + return JSON.stringify({ error: blockerError }) + } + } + } + if (input.add_blocked_by) { + for (const dependencyId of input.add_blocked_by) { + const dependencyError = validateTaskId(dependencyId) + if (dependencyError) { + return JSON.stringify({ error: dependencyError }) + } + } + } + const task = updateTeamTask(input.team_name, input.task_id, { + status: input.status, + owner: input.owner, + subject: input.subject, + description: input.description, + activeForm: input.active_form, + addBlocks: input.add_blocks, + addBlockedBy: input.add_blocked_by, + metadata: input.metadata, + }) + + if (input.owner !== undefined) { + notifyOwnerAssignment(input.team_name, task, actor) + } + + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/team-task-update.ts b/src/tools/agent-teams/team-task-update.ts new file mode 100644 index 0000000000..412afa3ce3 --- /dev/null +++ b/src/tools/agent-teams/team-task-update.ts @@ -0,0 +1,204 @@ +import { existsSync, readdirSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { validateTaskId, validateTeamName } from "./name-validation" +import { getTeamTaskDir, getTeamTaskPath } from "./paths" +import { + addPendingEdge, + createPendingEdgeMap, + ensureDependenciesCompleted, + ensureForwardStatusTransition, + wouldCreateCycle, +} from "./team-task-dependency" +import { + applyAddedBlockedBy, + applyAddedBlocks, + removeCompletedTaskFromDependents, + removeDeletedTaskReferences, +} from "./dependency-writer" +import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types" +import { withTeamTaskLock } from "./team-task-store" + +export interface TeamTaskUpdatePatch { + status?: TeamTaskStatus + owner?: string + subject?: string + description?: string + activeForm?: string + addBlocks?: string[] + addBlockedBy?: string[] + metadata?: Record +} + +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function assertValidTaskId(taskId: string): void { + const validationError = validateTaskId(taskId) + if (validationError) { + throw new Error(validationError) + } +} + +function writeTaskToPath(path: string, task: TeamTask): void { + writeJsonAtomic(path, TeamTaskSchema.parse(task)) +} + +function assertPatchTaskReferences(patch: TeamTaskUpdatePatch): void { + for (const blockedTaskId of patch.addBlocks ?? []) { + assertValidTaskId(blockedTaskId) + } + + for (const blockerId of patch.addBlockedBy ?? []) { + assertValidTaskId(blockerId) + } +} + +export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask { + assertValidTeamName(teamName) + assertValidTaskId(taskId) + assertPatchTaskReferences(patch) + + return withTeamTaskLock(teamName, () => { + const taskDir = getTeamTaskDir(teamName) + const taskPath = getTeamTaskPath(teamName, taskId) + const currentTask = readJsonSafe(taskPath, TeamTaskSchema) + if (!currentTask) { + throw new Error("team_task_not_found") + } + + const cache = new Map() + cache.set(taskId, currentTask) + + const readTask = (id: string): TeamTask | null => { + if (cache.has(id)) { + return cache.get(id) ?? null + } + const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema) + cache.set(id, loaded) + return loaded + } + + const pendingEdges = createPendingEdgeMap() + + if (patch.addBlocks) { + for (const blockedTaskId of patch.addBlocks) { + if (blockedTaskId === taskId) { + throw new Error("team_task_self_block") + } + if (!readTask(blockedTaskId)) { + throw new Error(`team_task_reference_not_found:${blockedTaskId}`) + } + addPendingEdge(pendingEdges, blockedTaskId, taskId) + } + + for (const blockedTaskId of patch.addBlocks) { + if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) { + throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`) + } + } + } + + if (patch.addBlockedBy) { + for (const blockerId of patch.addBlockedBy) { + if (blockerId === taskId) { + throw new Error("team_task_self_dependency") + } + if (!readTask(blockerId)) { + throw new Error(`team_task_reference_not_found:${blockerId}`) + } + addPendingEdge(pendingEdges, taskId, blockerId) + } + + for (const blockerId of patch.addBlockedBy) { + if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) { + throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`) + } + } + } + + if (patch.status && patch.status !== "deleted") { + ensureForwardStatusTransition(currentTask.status, patch.status) + } + + const effectiveStatus = patch.status ?? currentTask.status + const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])])) + const shouldValidateDependencies = + (patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted" + + if (shouldValidateDependencies) { + ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask) + } + + let nextTask: TeamTask = { ...currentTask } + + if (patch.subject !== undefined) { + nextTask.subject = patch.subject + } + if (patch.description !== undefined) { + nextTask.description = patch.description + } + if (patch.activeForm !== undefined) { + nextTask.activeForm = patch.activeForm + } + if (patch.owner !== undefined) { + nextTask.owner = patch.owner === "" ? undefined : patch.owner + } + + const pendingWrites = new Map() + + if (patch.addBlocks) { + applyAddedBlocks({ teamName, taskId, task: nextTask, pendingWrites, readTask }, patch.addBlocks) + } + + if (patch.addBlockedBy) { + applyAddedBlockedBy({ teamName, taskId, task: nextTask, pendingWrites, readTask }, patch.addBlockedBy) + } + + if (patch.metadata !== undefined) { + const merged: Record = { ...(nextTask.metadata ?? {}) } + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === null) { + delete merged[key] + } else { + merged[key] = value + } + } + nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined + } + + if (patch.status !== undefined) { + nextTask.status = patch.status + } + + const allTaskIds = readdirSync(taskDir) + .filter((file) => file.endsWith(".json") && file.startsWith("T-")) + .map((file) => file.replace(/\.json$/, "")) + + if (nextTask.status === "completed") { + removeCompletedTaskFromDependents(teamName, taskId, allTaskIds, pendingWrites, readTask) + } + + if (patch.status === "deleted") { + removeDeletedTaskReferences(teamName, taskId, allTaskIds, pendingWrites, readTask) + } + + for (const [path, task] of pendingWrites.entries()) { + writeTaskToPath(path, task) + } + + if (patch.status === "deleted") { + if (existsSync(taskPath)) { + unlinkSync(taskPath) + } + return TeamTaskSchema.parse({ ...nextTask, status: "deleted" }) + } + + writeTaskToPath(taskPath, nextTask) + return TeamTaskSchema.parse(nextTask) + }) +} diff --git a/src/tools/agent-teams/teammate-parent-context.test.ts b/src/tools/agent-teams/teammate-parent-context.test.ts new file mode 100644 index 0000000000..ec4ecc307e --- /dev/null +++ b/src/tools/agent-teams/teammate-parent-context.test.ts @@ -0,0 +1,36 @@ +/// +import { describe, expect, test } from "bun:test" +import { buildTeamParentToolContext } from "./teammate-parent-context" + +describe("agent-teams teammate parent context", () => { + test("forwards incoming abort signal to parent context resolver", () => { + //#given + const abortSignal = new AbortController().signal + + //#when + const parentToolContext = buildTeamParentToolContext({ + sessionID: "ses-main", + messageID: "msg-main", + agent: "sisyphus", + abort: abortSignal, + }) + + //#then + expect(parentToolContext.abort).toBe(abortSignal) + expect(parentToolContext.sessionID).toBe("ses-main") + expect(parentToolContext.messageID).toBe("msg-main") + expect(parentToolContext.agent).toBe("sisyphus") + }) + + test("leaves agent undefined if missing in tool context", () => { + //#when + const parentToolContext = buildTeamParentToolContext({ + sessionID: "ses-main", + messageID: "msg-main", + abort: new AbortController().signal, + }) + + //#then + expect(parentToolContext.agent).toBeUndefined() + }) +}) diff --git a/src/tools/agent-teams/teammate-parent-context.ts b/src/tools/agent-teams/teammate-parent-context.ts new file mode 100644 index 0000000000..a9966dd118 --- /dev/null +++ b/src/tools/agent-teams/teammate-parent-context.ts @@ -0,0 +1,17 @@ +import type { ParentContext } from "../delegate-task/executor" +import { resolveParentContext } from "../delegate-task/executor" +import type { ToolContextWithMetadata } from "../delegate-task/types" +import type { TeamToolContext } from "./types" + +export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata { + return { + sessionID: context.sessionID, + messageID: context.messageID, + agent: context.agent, + abort: context.abort ?? new AbortController().signal, + } +} + +export function resolveTeamParentContext(context: TeamToolContext): ParentContext { + return resolveParentContext(buildTeamParentToolContext(context)) +} diff --git a/src/tools/agent-teams/teammate-prompts.ts b/src/tools/agent-teams/teammate-prompts.ts new file mode 100644 index 0000000000..0519959670 --- /dev/null +++ b/src/tools/agent-teams/teammate-prompts.ts @@ -0,0 +1,28 @@ +export function buildLaunchPrompt( + teamName: string, + teammateName: string, + userPrompt: string, + categoryPromptAppend?: string, +): string { + const sections = [ + `You are teammate "${teammateName}" in team "${teamName}".`, + `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`, + "Initial assignment:", + userPrompt, + ] + + if (categoryPromptAppend) { + sections.push("Category guidance:", categoryPromptAppend) + } + + return sections.join("\n\n") +} + +export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string { + return [ + `New team message for "${teammateName}" in team "${teamName}".`, + `Summary: ${summary}`, + "Content:", + content, + ].join("\n\n") +} diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts new file mode 100644 index 0000000000..38b87ffdca --- /dev/null +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -0,0 +1,197 @@ +import type { BackgroundManager } from "../../features/background-agent" +import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store" +import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store" +import type { TeamTeammateMember, TeamToolContext } from "./types" +import { resolveTeamParentContext } from "./teammate-parent-context" +import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts" +import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution" + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function resolveLaunchFailureMessage(status: string | undefined, error: string | undefined): string { + if (status === "error") { + return error ? `teammate_launch_failed:${error}` : "teammate_launch_failed" + } + + if (status === "cancelled") { + return "teammate_launch_cancelled" + } + + return "teammate_launch_timeout" +} + +export interface SpawnTeammateParams { + teamName: string + name: string + prompt: string + category: string + subagentType: string + model?: string + planModeRequired: boolean + context: TeamToolContext + manager: BackgroundManager + categoryContext?: TeamCategoryContext +} + +export async function spawnTeammate(params: SpawnTeammateParams): Promise { + const parentContext = resolveTeamParentContext(params.context) + const execution = await resolveSpawnExecution( + { + teamName: params.teamName, + name: params.name, + prompt: params.prompt, + category: params.category, + subagentType: params.subagentType, + model: params.model, + manager: params.manager, + categoryContext: params.categoryContext, + }, + parentContext, + ) + + let teammate: TeamTeammateMember | undefined + let launchedTaskID: string | undefined + + updateTeamConfig(params.teamName, (current) => { + if (getTeamMember(current, params.name)) { + throw new Error("teammate_already_exists") + } + + teammate = { + agentId: `${params.name}@${params.teamName}`, + name: params.name, + agentType: execution.agentType, + category: params.category, + model: execution.teammateModel, + prompt: params.prompt, + color: assignNextColor(current), + planModeRequired: params.planModeRequired, + joinedAt: Date.now(), + cwd: process.cwd(), + subscriptions: [], + backendType: "native", + isActive: false, + } + + return upsertTeammate(current, teammate) + }) + + if (!teammate) { + throw new Error("teammate_create_failed") + } + + try { + ensureInbox(params.teamName, params.name) + sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color) + + const launched = await params.manager.launch({ + description: `[team:${params.teamName}] ${params.name}`, + prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend), + agent: execution.agentType, + parentSessionID: parentContext.sessionID, + parentMessageID: parentContext.messageID, + parentModel: parentContext.model, + ...(execution.launchModel ? { model: execution.launchModel } : {}), + ...(params.category ? { category: params.category } : {}), + parentAgent: parentContext.agent, + }) + launchedTaskID = launched.id + + const start = Date.now() + let sessionID = launched.sessionID + let latestStatus: string | undefined + let latestError: string | undefined + while (!sessionID && Date.now() - start < 30_000) { + await delay(50) + const task = params.manager.getTask(launched.id) + latestStatus = task?.status + latestError = task?.error + if (task?.status === "error" || task?.status === "cancelled") { + throw new Error(resolveLaunchFailureMessage(task.status, task.error)) + } + sessionID = task?.sessionID + } + + if (!sessionID) { + throw new Error(resolveLaunchFailureMessage(latestStatus, latestError)) + } + + const nextMember: TeamTeammateMember = { + ...teammate, + isActive: true, + backgroundTaskID: launched.id, + sessionID, + } + + updateTeamConfig(params.teamName, (current) => upsertTeammate(current, nextMember)) + return nextMember + } catch (error) { + const originalError = error + + if (launchedTaskID) { + await params.manager + .cancelTask(launchedTaskID, { + source: "team_launch_failed", + abortSession: true, + skipNotification: true, + }) + .catch(() => undefined) + } + + try { + updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name)) + } catch (cleanupError) { + void cleanupError + } + + try { + clearInbox(params.teamName, params.name) + } catch (cleanupError) { + void cleanupError + } + + throw originalError + } +} + +export async function resumeTeammateWithMessage( + manager: BackgroundManager, + context: TeamToolContext, + teamName: string, + teammate: TeamTeammateMember, + summary: string, + content: string, +): Promise { + if (!teammate.sessionID) { + return + } + + const parentContext = resolveTeamParentContext(context) + + try { + await manager.resume({ + sessionId: teammate.sessionID, + prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content), + parentSessionID: parentContext.sessionID, + parentMessageID: parentContext.messageID, + parentModel: parentContext.model, + parentAgent: parentContext.agent, + }) + } catch { + return + } +} + +export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise { + if (!teammate.backgroundTaskID) { + return + } + + await manager.cancelTask(teammate.backgroundTaskID, { + source: "team_force_kill", + abortSession: true, + skipNotification: true, + }) +} diff --git a/src/tools/agent-teams/teammate-spawn-execution.ts b/src/tools/agent-teams/teammate-spawn-execution.ts new file mode 100644 index 0000000000..b5df7bf8a1 --- /dev/null +++ b/src/tools/agent-teams/teammate-spawn-execution.ts @@ -0,0 +1,119 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { CategoriesConfig } from "../../config/schema" +import type { BackgroundManager } from "../../features/background-agent" +import type { ParentContext } from "../delegate-task/executor" +import { resolveCategoryExecution } from "../delegate-task/executor" +import type { DelegateTaskArgs } from "../delegate-task/types" + +function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { + if (!model) { + return undefined + } + + const separatorIndex = model.indexOf("/") + if (separatorIndex <= 0 || separatorIndex >= model.length - 1) { + throw new Error("invalid_model_override_format") + } + + return { + providerID: model.slice(0, separatorIndex), + modelID: model.slice(separatorIndex + 1), + } +} + +async function getSystemDefaultModel(client: PluginInput["client"]): Promise { + try { + const openCodeConfig = await client.config.get() + return (openCodeConfig as { data?: { model?: string } })?.data?.model + } catch { + return undefined + } +} + +export interface TeamCategoryContext { + client: PluginInput["client"] + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string +} + +export interface SpawnExecutionRequest { + teamName: string + name: string + prompt: string + category: string + subagentType: string + model?: string + manager: BackgroundManager + categoryContext?: TeamCategoryContext +} + +export interface SpawnExecutionResult { + agentType: string + teammateModel: string + launchModel?: { providerID: string; modelID: string; variant?: string } + categoryPromptAppend?: string +} + +export async function resolveSpawnExecution( + request: SpawnExecutionRequest, + parentContext: ParentContext, +): Promise { + if (request.model) { + const launchModel = parseModel(request.model) + return { + agentType: request.subagentType, + teammateModel: request.model, + ...(launchModel ? { launchModel } : {}), + } + } + + if (!request.categoryContext?.client) { + return { + agentType: request.subagentType, + teammateModel: "native", + } + } + + const inheritedModel = parentContext.model + ? `${parentContext.model.providerID}/${parentContext.model.modelID}` + : undefined + + const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client) + + const delegateArgs: DelegateTaskArgs = { + description: `[team:${request.teamName}] ${request.name}`, + prompt: request.prompt, + category: request.category, + subagent_type: "sisyphus-junior", + run_in_background: true, + load_skills: [], + } + + const resolution = await resolveCategoryExecution( + delegateArgs, + { + manager: request.manager, + client: request.categoryContext.client, + directory: process.cwd(), + userCategories: request.categoryContext.userCategories, + sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel, + }, + inheritedModel, + systemDefaultModel, + ) + + if (resolution.error) { + throw new Error(resolution.error) + } + + if (!resolution.categoryModel) { + throw new Error("category_model_not_resolved") + } + + return { + agentType: resolution.agentToUse, + teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`, + launchModel: resolution.categoryModel, + categoryPromptAppend: resolution.categoryPromptAppend, + } +} diff --git a/src/tools/agent-teams/teammate-tools.test.ts b/src/tools/agent-teams/teammate-tools.test.ts new file mode 100644 index 0000000000..aced47da98 --- /dev/null +++ b/src/tools/agent-teams/teammate-tools.test.ts @@ -0,0 +1,97 @@ +/// +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 type { BackgroundManager } from "../../features/background-agent" +import { createAgentTeamsTools } from "./tools" + +interface TestToolContext { + sessionID: string + messageID: string + agent: string + abort: AbortSignal +} + +interface MockManagerHandles { + manager: BackgroundManager + launchCalls: Array> +} + +function createMockManager(): MockManagerHandles { + const launchCalls: Array> = [] + + const manager = { + launch: async (args: Record) => { + launchCalls.push(args) + return { id: `bg-${launchCalls.length}`, sessionID: `ses-worker-${launchCalls.length}` } + }, + getTask: () => undefined, + resume: async () => ({ id: "resume-1" }), + cancelTask: async () => true, + } as unknown as BackgroundManager + + return { manager, launchCalls } +} + +function createContext(sessionID = "ses-main"): TestToolContext { + return { + sessionID, + messageID: "msg-main", + agent: "sisyphus", + abort: new AbortController().signal, + } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: TestToolContext, +): Promise { + const output = await tools[toolName].execute(args, context) + return JSON.parse(output) +} + +describe("agent-teams teammate tools", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-teammate-tools-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("spawn_teammate requires lead session authorization", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-lead") + const teammateContext = createContext("ses-worker") + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + //#when + const unauthorized = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + expect(launchCalls).toHaveLength(0) + }) +}) diff --git a/src/tools/agent-teams/teammate-tools.ts b/src/tools/agent-teams/teammate-tools.ts new file mode 100644 index 0000000000..b67dd123ed --- /dev/null +++ b/src/tools/agent-teams/teammate-tools.ts @@ -0,0 +1,200 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { PluginInput } from "@opencode-ai/plugin" +import type { CategoriesConfig } from "../../config/schema" +import type { BackgroundManager } from "../../features/background-agent" +import { clearInbox } from "./inbox-store" +import { validateAgentName, validateTeamName } from "./name-validation" +import { + TeamForceKillInputSchema, + TeamProcessShutdownInputSchema, + TeamSpawnInputSchema, + TeamToolContext, +} from "./types" +import { isTeammateMember } from "./team-member-utils" +import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store" +import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime" +import { resetOwnerTasks } from "./team-task-store" + +export interface AgentTeamsSpawnOptions { + client?: PluginInput["client"] + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string +} + +async function shutdownTeammateWithCleanup( + manager: BackgroundManager, + context: TeamToolContext, + teamName: string, + agentName: string, +): Promise { + const config = readTeamConfigOrThrow(teamName) + if (context.sessionID !== config.leadSessionId) { + return "unauthorized_lead_session" + } + + const member = getTeamMember(config, agentName) + if (!member || !isTeammateMember(member)) { + return "teammate_not_found" + } + + await cancelTeammateRun(manager, member) + let removed = false + + updateTeamConfig(teamName, (current) => { + const refreshedMember = getTeamMember(current, agentName) + if (!refreshedMember || !isTeammateMember(refreshedMember)) { + return current + } + removed = true + return removeTeammate(current, agentName) + }) + + try { + if (removed) { + clearInbox(teamName, agentName) + } + } finally { + resetOwnerTasks(teamName, agentName) + } + return null +} + +export function createSpawnTeammateTool(manager: BackgroundManager, options?: AgentTeamsSpawnOptions): ToolDefinition { + return tool({ + description: "Spawn a teammate using native internal agent execution.", + args: { + team_name: tool.schema.string().describe("Team name"), + name: tool.schema.string().describe("Teammate name"), + prompt: tool.schema.string().describe("Initial teammate prompt"), + category: tool.schema.string().describe("Required category for teammate metadata and routing"), + subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"), + model: tool.schema.string().optional().describe("Optional model override in provider/model format"), + plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamSpawnInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + + const agentError = validateAgentName(input.name) + if (agentError) { + return JSON.stringify({ error: agentError }) + } + + if (!input.category.trim()) { + return JSON.stringify({ error: "category_required" }) + } + + if (input.subagent_type && input.subagent_type !== "sisyphus-junior") { + return JSON.stringify({ error: "category_conflicts_with_subagent_type" }) + } + + const config = readTeamConfigOrThrow(input.team_name) + if (context.sessionID !== config.leadSessionId) { + return JSON.stringify({ error: "unauthorized_lead_session" }) + } + + const resolvedSubagentType = input.subagent_type ?? "sisyphus-junior" + + const teammate = await spawnTeammate({ + teamName: input.team_name, + name: input.name, + prompt: input.prompt, + category: input.category, + subagentType: resolvedSubagentType, + model: input.model, + planModeRequired: input.plan_mode_required ?? false, + context, + manager, + categoryContext: options?.client + ? { + client: options.client, + userCategories: options.userCategories, + sisyphusJuniorModel: options.sisyphusJuniorModel, + } + : undefined, + }) + + return JSON.stringify({ + agent_id: teammate.agentId, + name: teammate.name, + team_name: input.team_name, + session_id: teammate.sessionID, + task_id: teammate.backgroundTaskID, + }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" }) + } + }, + }) +} + +export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Force stop a teammate and clean up ownership state.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Teammate name"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamForceKillInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const agentError = validateAgentName(input.agent_name) + if (agentError) { + return JSON.stringify({ error: agentError }) + } + + const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.agent_name) + if (shutdownError) { + return JSON.stringify({ error: shutdownError }) + } + + return JSON.stringify({ success: true, message: `${input.agent_name} stopped` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" }) + } + }, + }) +} + +export function createProcessShutdownTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Teammate name"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamProcessShutdownInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + if (input.agent_name === "team-lead") { + return JSON.stringify({ error: "cannot_shutdown_team_lead" }) + } + const agentError = validateAgentName(input.agent_name) + if (agentError) { + return JSON.stringify({ error: agentError }) + } + + const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.agent_name) + if (shutdownError) { + return JSON.stringify({ error: shutdownError }) + } + + return JSON.stringify({ success: true, message: `${input.agent_name} removed` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts new file mode 100644 index 0000000000..c399e367ee --- /dev/null +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -0,0 +1,1430 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import { createAgentTeamsTools } from "./tools" +import { getTeamDir, getTeamInboxPath, getTeamTaskDir } from "./paths" + +interface LaunchCall { + description: string + prompt: string + agent: string + category?: string + parentSessionID: string + parentMessageID: string + parentAgent?: string + model?: { + providerID: string + modelID: string + variant?: string + } +} + +interface ResumeCall { + sessionId: string + prompt: string + parentSessionID: string + parentMessageID: string + parentAgent?: string +} + +interface CancelCall { + taskId: string + options?: unknown +} + +interface MockManagerHandles { + manager: BackgroundManager + launchCalls: LaunchCall[] + resumeCalls: ResumeCall[] + cancelCalls: CancelCall[] +} + +interface TestToolContext { + sessionID: string + messageID: string + agent: string + abort: AbortSignal +} + +function createMockManager(): MockManagerHandles { + const launchCalls: LaunchCall[] = [] + const resumeCalls: ResumeCall[] = [] + const cancelCalls: CancelCall[] = [] + const launchedTasks = new Map() + let launchCount = 0 + + const manager = { + launch: async (args: LaunchCall) => { + launchCount += 1 + launchCalls.push(args) + const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` } + launchedTasks.set(task.id, task) + return task + }, + getTask: (taskId: string) => launchedTasks.get(taskId), + resume: async (args: ResumeCall) => { + resumeCalls.push(args) + return { id: `resume-${resumeCalls.length}` } + }, + cancelTask: async (taskId: string, options?: unknown) => { + cancelCalls.push({ taskId, options }) + return true + }, + } as unknown as BackgroundManager + + return { manager, launchCalls, resumeCalls, cancelCalls } +} + +function createFailingLaunchManager(): { manager: BackgroundManager; cancelCalls: CancelCall[] } { + const cancelCalls: CancelCall[] = [] + + const manager = { + launch: async () => ({ id: "bg-fail" }), + getTask: () => ({ + id: "bg-fail", + parentSessionID: "ses-main", + parentMessageID: "msg-main", + description: "failed launch", + prompt: "prompt", + agent: "sisyphus-junior", + status: "error", + error: "launch failed", + }), + resume: async () => ({ id: "resume-unused" }), + cancelTask: async (taskId: string, options?: unknown) => { + cancelCalls.push({ taskId, options }) + return true + }, + } as unknown as BackgroundManager + + return { manager, cancelCalls } +} + +function createCategoryClientMock(): PluginInput["client"] { + return { + config: { + get: async () => ({ data: { model: "openai/gpt-5.3-codex" } }), + }, + provider: { + list: async () => ({ data: { connected: ["openai", "anthropic"] } }), + }, + model: { + list: async () => ({ + data: [ + { provider: "openai", id: "gpt-5.3-codex" }, + { provider: "anthropic", id: "claude-haiku-4-5" }, + ], + }), + }, + } as unknown as PluginInput["client"] +} + +function createContext(sessionID = "ses-main"): TestToolContext { + return { + sessionID, + messageID: "msg-main", + agent: "sisyphus", + abort: new AbortController().signal, + } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: TestToolContext, +): Promise { + const output = await tools[toolName].execute(args, context) + return JSON.parse(output) +} + +describe("agent-teams tools functional", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-tools-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("team_create/read_config/delete work with project-local storage", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + //#when + const created = await executeJsonTool( + tools, + "team_create", + { team_name: "core", description: "Core team" }, + context, + ) as { team_name: string; team_file_path: string; lead_agent_id: string } + + //#then + expect(created.team_name).toBe("core") + expect(created.lead_agent_id).toBe("team-lead@core") + expect(created.team_file_path).toBe(join(tempProjectDir, ".sisyphus", "agent-teams", "teams", "core", "config.json")) + expect(existsSync(created.team_file_path)).toBe(true) + expect(existsSync(getTeamInboxPath("core", "team-lead"))).toBe(true) + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + name: string + members: Array<{ name: string; model?: string }> + } + + //#then + expect(config.name).toBe("core") + expect(config.members.map((member) => member.name)).toEqual(["team-lead"]) + expect(config.members[0]?.model).toBe("native/team-lead") + + //#when + const deleted = await executeJsonTool(tools, "team_delete", { team_name: "core" }, context) as { + success: boolean + } + + //#then + expect(deleted.success).toBe(true) + expect(existsSync(getTeamDir("core"))).toBe(false) + expect(existsSync(getTeamTaskDir("core"))).toBe(false) + }) + + test("task tools create/update/get/list and emit assignment inbox message", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) + + //#when + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Draft release notes", + description: "Prepare release notes for next publish.", + }, + context, + ) as { id: string; status: string } + + //#then + expect(createdTask.id).toMatch(/^T-[a-f0-9-]+$/) + expect(createdTask.status).toBe("pending") + + //#when + const updatedTask = await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: createdTask.id, + owner: "worker_1", + status: "in_progress", + }, + context, + ) as { owner?: string; status: string } + + //#then + expect(updatedTask.owner).toBe("worker_1") + expect(updatedTask.status).toBe("in_progress") + + //#when + const fetchedTask = await executeJsonTool( + tools, + "team_task_get", + { team_name: "core", task_id: createdTask.id }, + context, + ) as { id: string; owner?: string } + const listedTasks = await executeJsonTool(tools, "team_task_list", { team_name: "core" }, context) as Array<{ id: string }> + const inbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ summary?: string; text: string }> + + //#then + expect(fetchedTask.id).toBe(createdTask.id) + expect(fetchedTask.owner).toBe("worker_1") + expect(listedTasks.some((task) => task.id === createdTask.id)).toBe(true) + expect(inbox.some((message) => message.summary === "task_assignment")).toBe(true) + const assignment = inbox.find((message) => message.summary === "task_assignment") + expect(assignment).toBeDefined() + const payload = JSON.parse(assignment!.text) as { type: string; taskId: string } + expect(payload.type).toBe("task_assignment") + expect(payload.taskId).toBe(createdTask.id) + + //#when + const clearedOwnerTask = await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: createdTask.id, + owner: "", + }, + context, + ) as { owner?: string } + + //#then + expect(clearedOwnerTask.owner).toBeUndefined() + }) + + test("task tools reject sessions outside the team", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Draft release notes", + description: "Prepare release notes for next publish.", + }, + leadContext, + ) as { id: string } + + const unknownContext = createContext("ses-unknown") + + //#when + const createUnauthorized = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Unauthorized create", + description: "Should fail", + }, + unknownContext, + ) as { error?: string } + + const listUnauthorized = await executeJsonTool( + tools, + "team_task_list", + { team_name: "core" }, + unknownContext, + ) as { error?: string } + + const getUnauthorized = await executeJsonTool( + tools, + "team_task_get", + { team_name: "core", task_id: createdTask.id }, + unknownContext, + ) as { error?: string } + + const updateUnauthorized = await executeJsonTool( + tools, + "team_task_update", + { team_name: "core", task_id: createdTask.id, status: "in_progress" }, + unknownContext, + ) as { error?: string } + + //#then + expect(createUnauthorized.error).toBe("unauthorized_task_session") + expect(listUnauthorized.error).toBe("unauthorized_task_session") + expect(getUnauthorized.error).toBe("unauthorized_task_session") + expect(updateUnauthorized.error).toBe("unauthorized_task_session") + }) + + test("team_task_update assignment notification sender follows actor session", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_2", + prompt: "Handle QA", + category: "quick", + }, + leadContext, + ) + + const task = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Validate rollout", + description: "Run preflight checks", + }, + leadContext, + ) as { id: string } + + //#when + const updated = await executeJsonTool( + tools, + "team_task_update", + { team_name: "core", task_id: task.id, owner: "worker_2" }, + createContext("ses-worker-1"), + ) as { owner?: string } + + const workerInbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_2", + unread_only: true, + mark_as_read: false, + }, + leadContext, + ) as Array<{ summary?: string; from: string; text: string }> + + //#then + expect(updated.owner).toBe("worker_2") + const assignment = workerInbox.find((message) => message.summary === "task_assignment") + expect(assignment).toBeDefined() + expect(assignment?.from).toBe("worker_1") + }) + + test("spawns teammate using category resolution like delegate-task", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager, { client: createCategoryClientMock() }) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawned = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) as { name?: string; error?: string } + + //#then + expect(spawned.error).toBeUndefined() + expect(spawned.name).toBe("worker_1") + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].agent).toBe("sisyphus-junior") + expect(launchCalls[0].category).toBe("quick") + expect(launchCalls[0].model).toBeDefined() + const resolvedModel = launchCalls[0].model! + expect(launchCalls[0].prompt).toContain("Category guidance:") + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + members: Array<{ name: string; category?: string; model?: string }> + } + + //#then + const teammate = config.members.find((member) => member.name === "worker_1") + expect(teammate).toBeDefined() + expect(teammate?.category).toBe("quick") + expect(teammate?.model).toBe(`${resolvedModel.providerID}/${resolvedModel.modelID}`) + }) + + test("spawn_teammate requires a category", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const result = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBeDefined() + expect(result.error).toContain("category") + }) + + test("rejects category with incompatible subagent_type", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager, { client: createCategoryClientMock() }) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const result = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + subagent_type: "oracle", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("category_conflicts_with_subagent_type") + }) + + test("rejects invalid task id input for task_get", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const result = await executeJsonTool( + tools, + "team_task_get", + { team_name: "core", task_id: "../../etc/passwd" }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("task_id_invalid") + }) + + test("requires owner to be a team member when setting task owner", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Investigate bug", + description: "Investigate and report root cause", + }, + context, + ) as { id: string } + + //#when + const result = await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: createdTask.id, + owner: "ghost_user", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("owner_not_in_team") + }) + + test("allows assigning team-lead as task owner", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Prepare checklist", + description: "Prepare release checklist", + }, + context, + ) as { id: string } + + //#when + const updated = await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: createdTask.id, + owner: "team-lead", + }, + context, + ) as { owner?: string } + + const leadInbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "team-lead", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ summary?: string; text: string }> + + //#then + expect(updated.owner).toBe("team-lead") + expect(leadInbox.some((message) => message.summary === "task_assignment")).toBe(true) + }) + + test("spawn_teammate + send_message + force_kill_teammate execute end-to-end", async () => { + //#given + const { manager, launchCalls, resumeCalls, cancelCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawned = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) as { name: string; session_id: string; task_id: string } + + //#then + expect(spawned.name).toBe("worker_1") + expect(spawned.session_id).toBe("ses-worker-1") + expect(spawned.task_id).toBe("bg-1") + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0]).toMatchObject({ + description: "[team:core] worker_1", + agent: "sisyphus-junior", + parentSessionID: "ses-main", + parentMessageID: "msg-main", + parentAgent: "sisyphus", + }) + + //#when + const sent = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1", + summary: "sync", + content: "Please update status.", + }, + context, + ) as { success: boolean } + + //#then + expect(sent.success).toBe(true) + expect(resumeCalls).toHaveLength(1) + expect(resumeCalls[0].sessionId).toBe("ses-worker-1") + + //#when + const invalidSender = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + sender: "ghost_user", + recipient: "worker_1", + summary: "sync", + content: "Please update status.", + }, + context, + ) as { error?: string } + + //#then + expect(invalidSender.error).toBe("sender_context_mismatch") + + //#given + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Follow-up", + description: "Collect teammate update", + }, + context, + ) as { id: string } + await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: createdTask.id, + owner: "worker_1", + status: "in_progress", + }, + context, + ) + + //#when + const killed = await executeJsonTool( + tools, + "force_kill_teammate", + { + team_name: "core", + agent_name: "worker_1", + }, + context, + ) as { success: boolean } + + //#then + expect(killed.success).toBe(true) + expect(cancelCalls).toHaveLength(1) + expect(cancelCalls[0].taskId).toBe("bg-1") + expect(cancelCalls[0].options).toEqual( + expect.objectContaining({ + source: "team_force_kill", + abortSession: true, + skipNotification: true, + }), + ) + + //#when + const configAfterKill = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + members: Array<{ name: string }> + } + const taskAfterKill = await executeJsonTool( + tools, + "team_task_get", + { + team_name: "core", + task_id: createdTask.id, + }, + context, + ) as { owner?: string; status: string } + + //#then + expect(configAfterKill.members.some((member) => member.name === "worker_1")).toBe(false) + expect(taskAfterKill.owner).toBeUndefined() + expect(taskAfterKill.status).toBe("pending") + }) + + test("process_shutdown_approved cancels and removes teammate", async () => { + //#given + const { manager, cancelCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + //#when + const shutdownResult = await executeJsonTool( + tools, + "process_shutdown_approved", + { + team_name: "core", + agent_name: "worker_1", + }, + leadContext, + ) as { success: boolean } + + //#then + expect(shutdownResult.success).toBe(true) + expect(cancelCalls).toHaveLength(1) + expect(cancelCalls[0].taskId).toBe("bg-1") + expect(cancelCalls[0].options).toEqual( + expect.objectContaining({ + source: "team_force_kill", + abortSession: true, + skipNotification: true, + }), + ) + + //#when + const configAfterShutdown = await executeJsonTool(tools, "read_config", { team_name: "core" }, leadContext) as { + members: Array<{ name: string }> + } + + //#then + expect(configAfterShutdown.members.some((member) => member.name === "worker_1")).toBe(false) + }) + + test("force_kill_teammate requires lead session authorization", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const unauthorized = await executeJsonTool( + tools, + "force_kill_teammate", + { + team_name: "core", + agent_name: "worker_1", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + }) + + test("process_shutdown_approved requires lead session authorization", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const unauthorized = await executeJsonTool( + tools, + "process_shutdown_approved", + { + team_name: "core", + agent_name: "worker_1", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + }) + + test("rolls back teammate and cancels background task when launch fails", async () => { + //#given + const { manager, cancelCalls } = createFailingLaunchManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) as { error?: string } + + //#then + expect(spawnResult.error).toBe("teammate_launch_failed:launch failed") + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + members: Array<{ name: string }> + } + + //#then + expect(config.members.map((member) => member.name)).toEqual(["team-lead"]) + expect(cancelCalls).toHaveLength(1) + expect(cancelCalls[0].taskId).toBe("bg-fail") + expect(cancelCalls[0].options).toEqual( + expect.objectContaining({ + source: "team_launch_failed", + abortSession: true, + skipNotification: true, + }), + ) + expect(existsSync(getTeamInboxPath("core", "worker_1"))).toBe(false) + }) + + test("returns explicit error on invalid model override format", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + model: "invalid-format", + }, + context, + ) as { error?: string } + + //#then + expect(spawnResult.error).toBe("invalid_model_override_format") + expect(launchCalls).toHaveLength(0) + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + members: Array<{ name: string }> + } + + //#then + expect(config.members.map((member) => member.name)).toEqual(["team-lead"]) + }) + + test("keeps full model id suffix when override contains extra slashes", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + model: "openai/gpt-5.3-codex/reasoning", + }, + context, + ) as { name?: string; error?: string } + + //#then + expect(spawnResult.error).toBeUndefined() + expect(spawnResult.name).toBe("worker_1") + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].model).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex/reasoning", + }) + }) + + test("read_inbox returns team_not_found for unknown team", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + //#when + const result = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "missing_team", + agent_name: "team-lead", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("team_not_found") + }) + + test("read_inbox denies cross-member access for non-lead sessions", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const unauthorized = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "team-lead", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_reader_session") + + //#when + const ownInbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + }, + teammateContext, + ) as unknown[] + + //#then + expect(Array.isArray(ownInbox)).toBe(true) + }) + + test("read_inbox returns messages with read=true when mark_as_read is enabled", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) + + //#when + const unreadBefore = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ read: boolean }> + + const markedRead = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: true, + }, + context, + ) as Array<{ read: boolean }> + + const unreadAfter = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ read: boolean }> + + //#then + expect(unreadBefore.length).toBeGreaterThan(0) + expect(unreadBefore.every((message) => message.read === false)).toBe(true) + expect(markedRead.length).toBeGreaterThan(0) + expect(markedRead.every((message) => message.read === true)).toBe(true) + expect(unreadAfter).toHaveLength(0) + }) + + test("rejects unknown session claiming team-lead identity", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + const unknownContext = createContext("ses-unknown") + + //#when + const sendResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + sender: "team-lead", + recipient: "team-lead", + summary: "restart", + content: "Lead session migrated", + }, + unknownContext, + ) as { success?: boolean; error?: string } + + //#then + expect(sendResult.success).toBeUndefined() + expect(sendResult.error).toBe("unauthorized_sender_session") + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, leadContext) as { + leadSessionId: string + } + + //#then + expect(config.leadSessionId).toBe("ses-main") + }) + + test("read_config rejects sessions outside the team", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + //#when + const result = await executeJsonTool( + tools, + "read_config", + { team_name: "core" }, + createContext("ses-unknown"), + ) as { error?: string } + + //#then + expect(result.error).toBe("unauthorized_reader_session") + }) + + test("read_config returns sanitized config for teammate sessions", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + //#when + const teammateView = await executeJsonTool( + tools, + "read_config", + { team_name: "core" }, + createContext("ses-worker-1"), + ) as { + team_name?: string + description?: string + lead_agent_id?: string + teammates?: Array<{ name: string }> + leadSessionId?: string + members?: unknown + } + + //#then + expect(teammateView.team_name).toBe("core") + expect(teammateView.description).toBe("") + expect(teammateView.lead_agent_id).toBe("team-lead@core") + expect(teammateView.teammates).toEqual(expect.arrayContaining([expect.objectContaining({ name: "worker_1" })])) + expect("leadSessionId" in teammateView).toBe(false) + expect("members" in teammateView).toBe(false) + }) + + test("rejects unknown session claiming team-lead inbox", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + const unknownContext = createContext("ses-unknown") + + //#when + const result = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "team-lead", + }, + unknownContext, + ) as { error?: string } + + //#then + expect(result.error).toBe("unauthorized_reader_session") + }) + + test("clears old inbox when teammate is removed then re-spawned", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "First run", + category: "quick", + }, + context, + ) + + await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1", + summary: "legacy", + content: "legacy payload", + }, + context, + ) + + await executeJsonTool( + tools, + "force_kill_teammate", + { + team_name: "core", + agent_name: "worker_1", + }, + context, + ) + + //#when + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Second run", + category: "quick", + }, + context, + ) + + const inbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ text: string; summary?: string }> + + //#then + expect(inbox.some((message) => message.text.includes("legacy payload"))).toBe(false) + expect(inbox.some((message) => message.summary === "initial_prompt" && message.text.includes("Second run"))).toBe(true) + }) + + test("cannot add pending blockers to already in-progress task without status change", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + const blocker = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Blocker", + description: "Unfinished blocker", + }, + context, + ) as { id: string } + + const mainTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Main", + description: "Main task", + }, + context, + ) as { id: string } + + await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: mainTask.id, + status: "in_progress", + }, + context, + ) + + //#when + const result = await executeJsonTool( + tools, + "team_task_update", + { + team_name: "core", + task_id: mainTask.id, + add_blocked_by: [blocker.id], + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe(`blocked_by_incomplete:${blocker.id}:pending`) + }) + + test("binds sender to calling context and rejects sender spoofing", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const spoofed = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + sender: "team-lead", + recipient: "worker_1", + summary: "spoof", + content: "I am lead", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(spoofed.error).toBe("sender_context_mismatch") + + //#when + const validFromContext = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "team-lead", + summary: "update", + content: "status from worker", + }, + teammateContext, + ) as { success?: boolean } + + //#then + expect(validFromContext.success).toBe(true) + }) +}) diff --git a/src/tools/agent-teams/tools.ts b/src/tools/agent-teams/tools.ts new file mode 100644 index 0000000000..ff32496fa2 --- /dev/null +++ b/src/tools/agent-teams/tools.ts @@ -0,0 +1,39 @@ +import type { ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" +import type { CategoriesConfig } from "../../config/schema" +import { createReadInboxTool, createSendMessageTool } from "./messaging-tools" +import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools" +import { createTeamTaskCreateTool, createTeamTaskGetTool, createTeamTaskListTool } from "./team-task-tools" +import { createTeamTaskUpdateTool } from "./team-task-update-tool" +import { createForceKillTeammateTool, createProcessShutdownTool, createSpawnTeammateTool } from "./teammate-tools" + +export interface AgentTeamsToolOptions { + client?: PluginInput["client"] + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string +} + +export function createAgentTeamsTools( + manager: BackgroundManager, + options?: AgentTeamsToolOptions, +): Record { + return { + team_create: createTeamCreateTool(), + team_delete: createTeamDeleteTool(), + spawn_teammate: createSpawnTeammateTool(manager, { + client: options?.client, + userCategories: options?.userCategories, + sisyphusJuniorModel: options?.sisyphusJuniorModel, + }), + send_message: createSendMessageTool(manager), + read_inbox: createReadInboxTool(), + read_config: createTeamReadConfigTool(), + team_task_create: createTeamTaskCreateTool(), + team_task_update: createTeamTaskUpdateTool(), + team_task_list: createTeamTaskListTool(), + team_task_get: createTeamTaskGetTool(), + force_kill_teammate: createForceKillTeammateTool(manager), + process_shutdown_approved: createProcessShutdownTool(manager), + } +} diff --git a/src/tools/agent-teams/types.test.ts b/src/tools/agent-teams/types.test.ts new file mode 100644 index 0000000000..ea9a1e4b92 --- /dev/null +++ b/src/tools/agent-teams/types.test.ts @@ -0,0 +1,30 @@ +/// +import { describe, expect, test } from "bun:test" +import { TeamTeammateMemberSchema } from "./types" + +describe("agent-teams types", () => { + test("rejects reserved agentType for teammate schema", () => { + //#given + const invalidTeammate = { + agentId: "worker@team", + name: "worker", + agentType: "team-lead", + category: "quick", + model: "native", + prompt: "do work", + color: "blue", + planModeRequired: false, + joinedAt: Date.now(), + cwd: "/tmp", + subscriptions: [], + backendType: "native", + isActive: false, + } + + //#when + const result = TeamTeammateMemberSchema.safeParse(invalidTeammate) + + //#then + expect(result.success).toBe(false) + }) +}) diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts new file mode 100644 index 0000000000..a1c375fb18 --- /dev/null +++ b/src/tools/agent-teams/types.ts @@ -0,0 +1,188 @@ +import { z } from "zod" +import { normalizeTeamRecipient } from "./team-recipient-utils" + +export const TEAM_COLOR_PALETTE = [ + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "cyan", + "red", +] as const + +export const TeamTaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"]) +export type TeamTaskStatus = z.infer + +export const TeamLeadMemberSchema = z.object({ + agentId: z.string(), + name: z.literal("team-lead"), + agentType: z.literal("team-lead"), + model: z.string(), + joinedAt: z.number(), + cwd: z.string(), + subscriptions: z.array(z.unknown()).default([]), +}).strict() + +export const TeamTeammateMemberSchema = z.object({ + agentId: z.string(), + name: z.string(), + agentType: z.string().refine((value) => value !== "team-lead", { + message: "agent_type_reserved", + }), + category: z.string(), + model: z.string(), + prompt: z.string(), + color: z.string(), + planModeRequired: z.boolean().default(false), + joinedAt: z.number(), + cwd: z.string(), + subscriptions: z.array(z.unknown()).default([]), + backendType: z.literal("native").default("native"), + isActive: z.boolean().default(false), + sessionID: z.string().optional(), + backgroundTaskID: z.string().optional(), +}).strict() + +export const TeamMemberSchema = z.union([TeamLeadMemberSchema, TeamTeammateMemberSchema]) + +export const TeamConfigSchema = z.object({ + name: z.string(), + description: z.string().default(""), + createdAt: z.number(), + leadAgentId: z.string(), + leadSessionId: z.string(), + members: z.array(TeamMemberSchema), +}).strict() + +export const TeamInboxMessageSchema = z.object({ + from: z.string(), + text: z.string(), + timestamp: z.string(), + read: z.boolean().default(false), + summary: z.string().optional(), + color: z.string().optional(), +}).strict() + +export const TeamTaskSchema = z.object({ + id: z.string(), + subject: z.string(), + description: z.string(), + activeForm: z.string().optional(), + status: TeamTaskStatusSchema, + blocks: z.array(z.string()).default([]), + blockedBy: z.array(z.string()).default([]), + owner: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}).strict() + +export const TeamSendMessageTypeSchema = z.enum([ + "message", + "broadcast", + "shutdown_request", + "shutdown_response", + "plan_approval_response", +]) + +export type TeamLeadMember = z.infer +export type TeamTeammateMember = z.infer +export type TeamMember = z.infer +export type TeamConfig = z.infer +export type TeamInboxMessage = z.infer +export type TeamTask = z.infer +export type TeamSendMessageType = z.infer + +export const TeamCreateInputSchema = z.object({ + team_name: z.string(), + description: z.string().optional(), +}) + +export const TeamDeleteInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamReadConfigInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamSpawnInputSchema = z.object({ + team_name: z.string(), + name: z.string(), + prompt: z.string(), + category: z.string(), + subagent_type: z.string().optional(), + model: z.string().optional(), + plan_mode_required: z.boolean().optional(), +}) + +export const TeamSendMessageInputSchema = z.object({ + team_name: z.string(), + type: TeamSendMessageTypeSchema, + recipient: z.string().optional().transform((value) => { + if (value === undefined) { + return undefined + } + + return normalizeTeamRecipient(value) + }), + content: z.string().optional(), + summary: z.string().optional(), + request_id: z.string().optional(), + approve: z.boolean().optional(), + sender: z.string().optional(), +}) + +export const TeamReadInboxInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), + unread_only: z.boolean().optional(), + mark_as_read: z.boolean().optional(), +}) + +export const TeamTaskCreateInputSchema = z.object({ + team_name: z.string(), + subject: z.string(), + description: z.string(), + active_form: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const TeamTaskUpdateInputSchema = z.object({ + team_name: z.string(), + task_id: z.string(), + status: TeamTaskStatusSchema.optional(), + owner: z.string().optional(), + subject: z.string().optional(), + description: z.string().optional(), + active_form: z.string().optional(), + add_blocks: z.array(z.string()).optional(), + add_blocked_by: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const TeamTaskListInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamTaskGetInputSchema = z.object({ + team_name: z.string(), + task_id: z.string(), +}) + +export const TeamForceKillInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), +}) + +export const TeamProcessShutdownInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), +}) + +export interface TeamToolContext { + sessionID: string + messageID: string + abort: AbortSignal + agent?: string +} diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 13d1973a44..10aa72f90b 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -26,7 +26,7 @@ export interface DelegateTaskArgs { export interface ToolContextWithMetadata { sessionID: string messageID: string - agent: string + agent?: string abort: AbortSignal metadata?: (input: { title?: string; metadata?: Record }) => void | Promise /** diff --git a/src/tools/index.ts b/src/tools/index.ts index a38a6c74ce..37060b5584 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -21,6 +21,7 @@ export { sessionExists } from "./session-manager/storage" export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash" export { createSkillTool } from "./skill" export { createSkillMcpTool } from "./skill-mcp" +export { createAgentTeamsTools } from "./agent-teams" import { createBackgroundOutput,