From 282e8ae9fd773190a04bb25fcd36ebe68bd07c78 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 30 Mar 2026 23:58:23 +0530 Subject: [PATCH 01/19] test: add error path coverage for recovery system Add tests for runtime.isAlive, workspace.destroy, and runtime.destroy error handling to ensure cleanup continues despite partial failures. Fixes test coverage gaps in validator and cleanup actions. Co-Authored-By: Claude Sonnet 4.5 --- .../src/__tests__/recovery-actions.test.ts | 116 +++++++++++++++++- .../src/__tests__/recovery-validator.test.ts | 90 ++++++++++++++ 2 files changed, 204 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 546262dd6..c645bcfab 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { readMetadataRaw } from "../metadata.js"; import { getSessionsDir } from "../paths.js"; -import { escalateSession, recoverSession } from "../recovery/actions.js"; +import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js"; import { runRecovery } from "../recovery/manager.js"; import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js"; import { @@ -13,7 +13,7 @@ import { type RecoveryAssessment, type RecoveryContext, } from "../recovery/types.js"; -import type { OrchestratorConfig, PluginRegistry } from "../types.js"; +import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js"; function makeConfig(rootDir: string): OrchestratorConfig { return { @@ -242,6 +242,118 @@ describe("escalateSession", () => { }); }); +describe("cleanupSession", () => { + let rootDir: string; + + afterEach(() => { + if (rootDir) { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => { + rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const config = makeConfig(rootDir); + const workspacePath = join(rootDir, "worktree"); + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")), + list: vi.fn(), + exists: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "workspace") return mockWorkspace; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const assessment = makeAssessment({ + action: "cleanup", + classification: "dead", + runtimeAlive: false, + workspaceExists: true, + workspacePath, + rawMetadata: { + ...makeAssessment().rawMetadata, + worktree: workspacePath, + }, + }); + const context = makeContext(rootDir); + + const result = await cleanupSession(assessment, config, registry, context); + const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path); + + expect(mockWorkspace.destroy).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(existsSync(join(sessionsDir, "app-1"))).toBe(false); + }); + + it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => { + rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const config = makeConfig(rootDir); + const workspacePath = join(rootDir, "worktree"); + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn(), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + list: vi.fn(), + exists: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const assessment = makeAssessment({ + action: "cleanup", + classification: "partial", + runtimeAlive: true, + workspaceExists: true, + workspacePath, + rawMetadata: { + ...makeAssessment().rawMetadata, + worktree: workspacePath, + }, + }); + const context = makeContext(rootDir); + + const result = await cleanupSession(assessment, config, registry, context); + const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path); + + expect(mockRuntime.destroy).toHaveBeenCalled(); + expect(mockWorkspace.destroy).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(existsSync(join(sessionsDir, "app-1"))).toBe(false); + }); +}); + describe("recovery manager and scanner", () => { let rootDir: string; diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index fa585db6d..cab682a4f 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -121,4 +121,94 @@ describe("recovery validator", () => { expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled(); expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled(); }); + + it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => { + rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + const projectPath = join(rootDir, "project"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn(), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn(), + list: vi.fn(), + exists: vi.fn().mockResolvedValue(true), + }; + const mockAgent: Agent = { + name: "mock-agent", + processName: "mock-agent", + getLaunchCommand: vi.fn(), + getEnvironment: vi.fn(), + detectActivity: vi.fn(), + getActivityState: vi.fn(), + isProcessRunning: vi.fn().mockResolvedValue(false), + getSessionInfo: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") return mockAgent; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const config: OrchestratorConfig = { + configPath: join(rootDir, "agent-orchestrator.yaml"), + port: 3000, + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "mock-agent", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + app: { + name: "app", + repo: "org/repo", + path: projectPath, + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + }; + const scanned: ScannedSession = { + sessionId: "app-1", + projectId: "app", + project: config.projects.app, + sessionsDir: getSessionsDir(config.configPath, projectPath), + rawMetadata: { + worktree: projectPath, + status: "working", + runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }), + }, + }; + + const assessment = await validateSession(scanned, config, registry); + + expect(mockRuntime.isAlive).toHaveBeenCalled(); + expect(assessment.runtimeAlive).toBe(false); + }); }); From 37777bc55bb82003ac6bb3dce7eb85222ce43bd0 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 00:30:03 +0530 Subject: [PATCH 02/19] test: add retry loop coverage for deleteSession Add comprehensive tests for retry logic including retry count, delays, error propagation, early exit, and not-found handling. Fixes Issue #5 Co-Authored-By: Claude Sonnet 4.5 --- .../src/__tests__/session-manager.test.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 packages/core/src/__tests__/session-manager.test.ts diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts new file mode 100644 index 000000000..bab650628 --- /dev/null +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createSessionManager } from "../session-manager.js"; +import { writeMetadata } from "../metadata.js"; +import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js"; +import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js"; + +// Mock child_process module +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +let ctx: TestContext; +let tmpDir: string; +let configPath: string; +let sessionsDir: string; +let mockRegistry: PluginRegistry; +let config: OrchestratorConfig; +let originalPath: string | undefined; + +beforeEach(() => { + ctx = setupTestContext(); + ({ tmpDir, configPath, sessionsDir, mockRegistry, config, originalPath } = ctx); + + // Create an opencode agent mock + const opencodeAgent: Agent = { + name: "opencode", + processName: "opencode", + getLaunchCommand: vi.fn().mockReturnValue("opencode start"), + getEnvironment: vi.fn().mockReturnValue({}), + detectActivity: vi.fn().mockReturnValue("active"), + getActivityState: vi.fn().mockResolvedValue({ state: "active" }), + isProcessRunning: vi.fn().mockResolvedValue(true), + getSessionInfo: vi.fn().mockResolvedValue(null), + }; + + // Update registry to include opencode agent + const originalGet = mockRegistry.get; + mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => { + if (slot === "agent" && name === "opencode") { + return opencodeAgent; + } + return originalGet(slot, name); + }); + + // Set project to use opencode agent + config.projects["my-app"]!.agent = "opencode"; +}); + +afterEach(() => { + teardownTestContext(ctx); + vi.restoreAllMocks(); +}); + +describe("deleteSession retry loop", () => { + it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => { + const { execFile } = await import("node:child_process"); + + // Setup: Create a session with opencode agent + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_123", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + let deleteCallCount = 0; + const mockError = new Error("OpenCode delete failed"); + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + cb(mockError, null, null); + } else if (argsArray[1] === "list") { + cb(null, { stdout: "[]", stderr: "" }, null); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + // Execute kill with purgeOpenCode option + await sm.kill("app-1", { purgeOpenCode: true }); + + // Verify delete was called 3 times (one for each retry) + expect(deleteCallCount).toBe(3); + }); + + it("verifies retry delays - confirms delays are 0ms, 200ms, 600ms", async () => { + const { execFile } = await import("node:child_process"); + vi.useFakeTimers(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_456", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const callTimes: number[] = []; + const mockError = new Error("OpenCode delete failed"); + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + callTimes.push(Date.now()); + cb(mockError, null, null); + } else if (argsArray[1] === "list") { + cb(null, { stdout: "[]", stderr: "" }, null); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const killPromise = sm.kill("app-2", { purgeOpenCode: true }); + + // Run all timers to completion + await vi.runAllTimersAsync(); + await killPromise; + + // Verify we have 3 calls + expect(callTimes).toHaveLength(3); + + // Calculate delays between calls + const delay1 = callTimes[1]! - callTimes[0]!; // Should be 200ms + const delay2 = callTimes[2]! - callTimes[1]!; // Should be 600ms + + expect(delay1).toBe(200); + expect(delay2).toBe(600); + + vi.useRealTimers(); + }); + + it("verifies final error propagation - last error is thrown after all retries fail", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-3", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_789", + runtimeHandle: JSON.stringify(makeHandle("rt-3")), + }); + + const lastError = new Error("Final error after retries"); + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`); + cb(error, null, null); + } else if (argsArray[1] === "list") { + cb(null, { stdout: "[]", stderr: "" }, null); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + // The kill function catches the error and continues, so we need to verify + // that the error was thrown internally but handled gracefully + await sm.kill("app-3", { purgeOpenCode: true }); + + // Verify all 3 delete attempts were made + expect(deleteCallCount).toBe(3); + }); + + it("verifies early success exit - stops after first success without unnecessary retries", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-4", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_abc", + runtimeHandle: JSON.stringify(makeHandle("rt-4")), + }); + + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + if (deleteCallCount === 1) { + // First attempt fails + cb(new Error("First attempt failed"), null, null); + } else { + // Second attempt succeeds + cb(null, { stdout: "", stderr: "" }, null); + } + } else if (argsArray[1] === "list") { + cb(null, { stdout: "[]", stderr: "" }, null); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-4", { purgeOpenCode: true }); + + // Verify delete was called exactly 2 times (failed once, succeeded on second) + expect(deleteCallCount).toBe(2); + }); + + it("verifies session-not-found handling - exits gracefully without retrying", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-5", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_def", + runtimeHandle: JSON.stringify(makeHandle("rt-5")), + }); + + const notFoundError = new Error("Session not found: ses_test_def") as Error & { + stderr?: string; + stdout?: string; + }; + notFoundError.stderr = "Error: session not found: ses_test_def"; + + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + cb(notFoundError, null, null); + } else if (argsArray[1] === "list") { + cb(null, { stdout: "[]", stderr: "" }, null); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-5", { purgeOpenCode: true }); + + // Verify delete was called only once - no retries for "not found" errors + expect(deleteCallCount).toBe(1); + }); +}); From 4c39dbe3adc1b22079f21adb13163d118a2766e9 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 04:24:30 +0530 Subject: [PATCH 03/19] refactor(plugin-registry): improve plugin specifier resolution and error handling --- packages/core/src/plugin-registry.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 72e0783c5..14d18c266 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -183,6 +183,7 @@ function resolveLocalPluginEntrypoint(pluginPath: string): string | null { function inferPackageSpecifier(value: string | undefined): string | null { if (!value) return null; + if (value.startsWith(".") || value.startsWith("/")) return null; return value.startsWith("@") || value.includes("/") ? value : null; } @@ -190,13 +191,19 @@ function resolvePluginSpecifier( plugin: InstalledPluginConfig, config: OrchestratorConfig, ): string | null { - if (plugin.path) { - const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath); - const entrypoint = resolveLocalPluginEntrypoint(absolutePath); - return entrypoint ? pathToFileURL(entrypoint).href : null; + switch (plugin.source) { + case "local": { + if (!plugin.path) return null; + const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath); + const entrypoint = resolveLocalPluginEntrypoint(absolutePath); + return entrypoint ? pathToFileURL(entrypoint).href : null; + } + case "registry": + case "npm": + return plugin.package ?? inferPackageSpecifier(plugin.name); + default: + return null; } - - return plugin.package ?? inferPackageSpecifier(plugin.name); } export function createPluginRegistry(): PluginRegistry { @@ -258,7 +265,10 @@ export function createPluginRegistry(): PluginRegistry { if (plugin.enabled === false) continue; const specifier = resolvePluginSpecifier(plugin, config); - if (!specifier) continue; + if (!specifier) { + console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`); + continue; + } try { const mod = normalizeImportedPluginModule(await doImport(specifier)); @@ -266,8 +276,8 @@ export function createPluginRegistry(): PluginRegistry { const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config); this.register(mod, pluginConfig); - } catch { - // External plugin unavailable or invalid — continue loading the rest. + } catch (error) { + console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error); } } }, From 1271ef8fbde893d49cca7c6b4b04086695833b2a Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 04:57:25 +0530 Subject: [PATCH 04/19] fix: add missing default exports fallback and remove dead code - Add top-level "default" key fallback in CLI's resolvePackageExportsEntry to match core's plugin-registry.ts (fixes local plugin resolution for packages using exports: { "default": "./dist/index.js" }) - Remove dead exports: MARKETPLACE_PLUGIN_CATALOG, printPluginList, getAgentFromRegistry, resolveInstalledPluginSpecifier - Fix incomplete string escaping in plugin scaffold (backslash/newline) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/plugin.ts | 4 ---- packages/cli/src/lib/plugin-marketplace.ts | 4 +++- packages/cli/src/lib/plugin-scaffold.ts | 4 ++-- packages/cli/src/lib/plugin-store.ts | 10 ---------- packages/cli/src/lib/plugins.ts | 11 ----------- 5 files changed, 5 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index b4710eb18..1c141acbf 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -251,10 +251,6 @@ async function verifyPluginDescriptor( return normalizedDescriptor; } -function printPluginList(config: OrchestratorConfig | null, installedOnly: boolean): void { - printPluginListFromCatalog(config, installedOnly, loadMarketplaceCatalog()); -} - function printPluginListFromCatalog( config: OrchestratorConfig | null, installedOnly: boolean, diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index c8f5190c6..85079cfc6 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -16,7 +16,6 @@ export interface MarketplacePluginEntry { } export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[]; -export const MARKETPLACE_PLUGIN_CATALOG = BUNDLED_MARKETPLACE_PLUGIN_CATALOG; export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL = "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json"; @@ -156,6 +155,9 @@ function resolvePackageExportsEntry(exportsField: unknown): string | null { const importEntry = exportsRecord["import"]; if (typeof importEntry === "string") return importEntry; + const defaultEntry = exportsRecord["default"]; + if (typeof defaultEntry === "string") return defaultEntry; + return null; } diff --git a/packages/cli/src/lib/plugin-scaffold.ts b/packages/cli/src/lib/plugin-scaffold.ts index e3520ba7a..518fc4d7b 100644 --- a/packages/cli/src/lib/plugin-scaffold.ts +++ b/packages/cli/src/lib/plugin-scaffold.ts @@ -119,8 +119,8 @@ function buildTsConfig(): string { function buildIndexTs(input: PluginScaffoldInput): string { const manifestName = normalizePluginName(input.displayName); - const displayName = input.displayName.replace(/"/g, '\\"'); - const description = input.description.replace(/"/g, '\\"'); + const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); return `import type { PluginModule } from "@composio/ao-core"; diff --git a/packages/cli/src/lib/plugin-store.ts b/packages/cli/src/lib/plugin-store.ts index 6b7684cb1..0f7db20e6 100644 --- a/packages/cli/src/lib/plugin-store.ts +++ b/packages/cli/src/lib/plugin-store.ts @@ -117,16 +117,6 @@ export function tryResolveInstalledPluginSpecifier(packageName: string): string } } -export function resolveInstalledPluginSpecifier(packageName: string): string { - const specifier = tryResolveInstalledPluginSpecifier(packageName); - if (!specifier) { - throw new Error( - `Package ${packageName} is not installed in the AO plugin store (${getPluginStoreRoot()}).`, - ); - } - return specifier; -} - export async function importPluginModuleFromSource(specifier: string): Promise { if (isPackageSpecifier(specifier)) { const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier); diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index dfd666ee6..215cd006c 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -35,17 +35,6 @@ export function getAgentByName(name: string): Agent { return plugin.create(); } -/** Resolve the Agent plugin from the shared registry. */ -export function getAgentFromRegistry( - registry: PluginRegistry, - config: OrchestratorConfig, - projectId?: string, -): Agent { - const agentName = - (projectId ? config.projects[projectId]?.agent : undefined) || config.defaults.agent; - return getAgentByNameFromRegistry(registry, agentName); -} - /** Get an agent by name from the shared registry. */ export function getAgentByNameFromRegistry(registry: PluginRegistry, name: string): Agent { const plugin = registry.get("agent", name); From 1df727782cd0077ced12bb9191e15d9691bb6210 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 05:00:30 +0530 Subject: [PATCH 05/19] fix: resolve pre-existing lint errors in plugin command - Remove unused `resolve` import from node:path - Add `{ cause: err }` to re-thrown errors (preserve-caught-error rule) - Fix import() type annotation in plugin test (consistent-type-imports) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/plugin.test.ts | 2 +- packages/cli/src/commands/plugin.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/__tests__/commands/plugin.test.ts b/packages/cli/__tests__/commands/plugin.test.ts index 17b528787..95a984c37 100644 --- a/packages/cli/__tests__/commands/plugin.test.ts +++ b/packages/cli/__tests__/commands/plugin.test.ts @@ -25,7 +25,7 @@ const { })); vi.mock("@composio/ao-core", async (importOriginal) => { - const actual = await importOriginal(); + const actual: typeof import("@composio/ao-core") = await importOriginal(); return { ...actual, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index 1c141acbf..c5eadca80 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -1,5 +1,4 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { @@ -131,6 +130,7 @@ async function installOrVerifyPlugin( rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); throw new Error( `${message}\nRollback failed for ${descriptor.package}: ${rollbackMessage}`, + { cause: err }, ); } } @@ -206,6 +206,7 @@ async function updateManagedPlugin( rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); throw new Error( `${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`, + { cause: err }, ); } } From 89d6f957232406f225c6f707f29b48cc41fbd836 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 05:04:29 +0530 Subject: [PATCH 06/19] fix: export runSetupAction, fix lint errors in plugin command - Export runSetupAction from setup.ts (fixes TS2459 build error) - Use rollbackErr as cause in re-thrown errors (preserve-caught-error) - Remove import() type annotation from test mock (consistent-type-imports) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/plugin.test.ts | 2 +- packages/cli/src/commands/plugin.ts | 4 ++-- packages/cli/src/commands/setup.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/__tests__/commands/plugin.test.ts b/packages/cli/__tests__/commands/plugin.test.ts index 95a984c37..ec54c939d 100644 --- a/packages/cli/__tests__/commands/plugin.test.ts +++ b/packages/cli/__tests__/commands/plugin.test.ts @@ -25,7 +25,7 @@ const { })); vi.mock("@composio/ao-core", async (importOriginal) => { - const actual: typeof import("@composio/ao-core") = await importOriginal(); + const actual = await importOriginal(); return { ...actual, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index c5eadca80..12bfea551 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -130,7 +130,7 @@ async function installOrVerifyPlugin( rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); throw new Error( `${message}\nRollback failed for ${descriptor.package}: ${rollbackMessage}`, - { cause: err }, + { cause: rollbackErr }, ); } } @@ -206,7 +206,7 @@ async function updateManagedPlugin( rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); throw new Error( `${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`, - { cause: err }, + { cause: rollbackErr }, ); } } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 451c92445..b5384c1fc 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -452,7 +452,7 @@ export function registerSetup(program: Command): void { }); } -async function runSetupAction(opts: SetupOptions): Promise { +export async function runSetupAction(opts: SetupOptions): Promise { const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; // --- Find existing config ------------------------------------------------ From 545bede0b4b1b7233e77f6cc4429819c093c7b38 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 05:08:13 +0530 Subject: [PATCH 07/19] fix: add JSON import attribute for Node.js 20 compatibility Add `with { type: "json" }` to the plugin-registry.json import to satisfy Node.js 20's ERR_IMPORT_ASSERTION_TYPE_MISSING requirement. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/lib/plugin-marketplace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 85079cfc6..53f4631d1 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core"; -import registryData from "../assets/plugin-registry.json"; +import registryData from "../assets/plugin-registry.json" with { type: "json" }; export interface MarketplacePluginEntry { id: string; From 6930f2ee1240cb8c3e2d466841f580384af40bba Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 05:11:24 +0530 Subject: [PATCH 08/19] fix: use createRequire for JSON import (Node16 module compat) Import attributes aren't supported with module: "Node16". Switch to createRequire for the plugin-registry.json import and copy assets to dist during build. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- packages/cli/src/lib/plugin-marketplace.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d45757226..b0609d3c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "tsc", + "build": "tsc && cp -r src/assets dist/", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 53f4631d1..8b15fa917 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -1,9 +1,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core"; -import registryData from "../assets/plugin-registry.json" with { type: "json" }; + +const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[]; export interface MarketplacePluginEntry { id: string; From 4d91457a011556402c1aeccd9256ad430bdd3365 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 10:14:59 +0530 Subject: [PATCH 09/19] test: ensure fake timers are restored in afterEach cleanup Co-Authored-By: Claude Sonnet 4.5 --- packages/core/src/__tests__/session-manager.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index bab650628..14f82abf9 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -49,6 +49,7 @@ beforeEach(() => { afterEach(() => { teardownTestContext(ctx); vi.restoreAllMocks(); + vi.useRealTimers(); }); describe("deleteSession retry loop", () => { From 4a68fd9f990efdd6a3d33ca1fad75a22b6610097 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 10:51:54 +0530 Subject: [PATCH 10/19] test(core): fix timeouts in session manager communication tests - Mock runtime output changes in OpenCode unit tests to trigger early delivery confirmation, reducing poll time from 3s to 500ms. - Increase timeout for confirmation fallback tests to 10s to accommodate process-spawn overhead of mock binaries on some systems. - Ensures reliable CI execution for @composio/ao-core tests. --- .../src/__tests__/session-manager/communication.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index a9cba56fd..cd1a3f391 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -215,6 +215,9 @@ describe("send", () => { runtimeHandle: JSON.stringify(makeHandle("rt-1")), }); + vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after"); + const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "hello"); @@ -247,6 +250,9 @@ describe("send", () => { runtimeHandle: JSON.stringify(makeHandle("rt-1")), }); + vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after"); + const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "hello"); @@ -351,7 +357,7 @@ describe("send", () => { makeHandle("rt-1"), "do not confirm on visibility", ); - }); + }, 10000); }); describe("remap", () => { From 8fdcaff11e0dd138257157a73d750173bb87d8de Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 13:10:04 +0530 Subject: [PATCH 11/19] test: fix execFile mock to match Node callback contract Co-Authored-By: Claude Sonnet 4.5 --- .../src/__tests__/session-manager.test.ts | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 14f82abf9..31b2d0266 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -4,10 +4,25 @@ import { writeMetadata } from "../metadata.js"; import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js"; import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js"; -// Mock child_process module -vi.mock("node:child_process", () => ({ - execFile: vi.fn(), -})); +// Mock child_process module with custom promisify +vi.mock("node:child_process", () => { + const execFileMock = vi.fn(); + // Implement custom promisify to return { stdout, stderr } objects + execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => { + return new Promise((resolve, reject) => { + execFileMock(...args, (error: any, stdout: string, stderr: string) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + } else { + resolve({ stdout, stderr }); + } + }); + }); + }; + return { + execFile: execFileMock, + }; +}); let ctx: TestContext; let tmpDir: string; @@ -77,9 +92,9 @@ describe("deleteSession retry loop", () => { const argsArray = Array.isArray(args) ? args : []; if (argsArray[1] === "delete") { deleteCallCount++; - cb(mockError, null, null); + cb(mockError, "", ""); } else if (argsArray[1] === "list") { - cb(null, { stdout: "[]", stderr: "" }, null); + cb(null, "[]", ""); } return null as any; }) as any); @@ -117,9 +132,9 @@ describe("deleteSession retry loop", () => { const argsArray = Array.isArray(args) ? args : []; if (argsArray[1] === "delete") { callTimes.push(Date.now()); - cb(mockError, null, null); + cb(mockError, "", ""); } else if (argsArray[1] === "list") { - cb(null, { stdout: "[]", stderr: "" }, null); + cb(null, "[]", ""); } return null as any; }) as any); @@ -168,9 +183,9 @@ describe("deleteSession retry loop", () => { if (argsArray[1] === "delete") { deleteCallCount++; const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`); - cb(error, null, null); + cb(error, "", ""); } else if (argsArray[1] === "list") { - cb(null, { stdout: "[]", stderr: "" }, null); + cb(null, "[]", ""); } return null as any; }) as any); @@ -209,13 +224,13 @@ describe("deleteSession retry loop", () => { deleteCallCount++; if (deleteCallCount === 1) { // First attempt fails - cb(new Error("First attempt failed"), null, null); + cb(new Error("First attempt failed"), "", ""); } else { // Second attempt succeeds - cb(null, { stdout: "", stderr: "" }, null); + cb(null, "", ""); } } else if (argsArray[1] === "list") { - cb(null, { stdout: "[]", stderr: "" }, null); + cb(null, "[]", ""); } return null as any; }) as any); @@ -255,9 +270,9 @@ describe("deleteSession retry loop", () => { const argsArray = Array.isArray(args) ? args : []; if (argsArray[1] === "delete") { deleteCallCount++; - cb(notFoundError, null, null); + cb(notFoundError, "", ""); } else if (argsArray[1] === "list") { - cb(null, { stdout: "[]", stderr: "" }, null); + cb(null, "[]", ""); } return null as any; }) as any); From 03e34266255be553c064395f0ee2f8778755a17c Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 13:12:35 +0530 Subject: [PATCH 12/19] test: clarify error propagation test scope in session-manager Co-Authored-By: Claude Sonnet 4.5 --- packages/core/src/__tests__/session-manager.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 31b2d0266..9f6fb79d5 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -159,7 +159,7 @@ describe("deleteSession retry loop", () => { vi.useRealTimers(); }); - it("verifies final error propagation - last error is thrown after all retries fail", async () => { + it("verifies all retries are attempted when deletion fails", async () => { const { execFile } = await import("node:child_process"); writeMetadata(sessionsDir, "app-3", { @@ -192,8 +192,8 @@ describe("deleteSession retry loop", () => { const sm = createSessionManager({ config, registry: mockRegistry }); - // The kill function catches the error and continues, so we need to verify - // that the error was thrown internally but handled gracefully + // The kill function catches and ignores deleteOpenCodeSession() failures, + // so this test verifies that all retry attempts are made despite errors await sm.kill("app-3", { purgeOpenCode: true }); // Verify all 3 delete attempts were made From 994297ca7e399342c6743c2cac4f26ac160f3814 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 13:14:52 +0530 Subject: [PATCH 13/19] test: clean up session directories in recovery-actions afterEach Co-Authored-By: Claude Sonnet 4.5 --- .../src/__tests__/recovery-actions.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index c645bcfab..25a66e2a8 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { readMetadataRaw } from "../metadata.js"; -import { getSessionsDir } from "../paths.js"; +import { getSessionsDir, getProjectBaseDir } from "../paths.js"; import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js"; import { runRecovery } from "../recovery/manager.js"; import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js"; @@ -101,6 +101,12 @@ describe("recoverSession", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); @@ -215,6 +221,12 @@ describe("escalateSession", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); @@ -247,6 +259,12 @@ describe("cleanupSession", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); @@ -359,6 +377,12 @@ describe("recovery manager and scanner", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); From 23f2f0dbeb4a06a923a9c8fa64f27cb84f6de25b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 13:20:45 +0530 Subject: [PATCH 14/19] fix: remove unused variable declarations in session-manager test Co-Authored-By: Claude Sonnet 4.5 --- packages/core/src/__tests__/session-manager.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 9f6fb79d5..39eb1cbb9 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -25,16 +25,13 @@ vi.mock("node:child_process", () => { }); let ctx: TestContext; -let tmpDir: string; -let configPath: string; let sessionsDir: string; let mockRegistry: PluginRegistry; let config: OrchestratorConfig; -let originalPath: string | undefined; beforeEach(() => { ctx = setupTestContext(); - ({ tmpDir, configPath, sessionsDir, mockRegistry, config, originalPath } = ctx); + ({ sessionsDir, mockRegistry, config } = ctx); // Create an opencode agent mock const opencodeAgent: Agent = { From 23e006b00c55a0106f64af9623b158d3a9984416 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 31 Mar 2026 13:22:30 +0530 Subject: [PATCH 15/19] fix: add type casts for mock promisify and registry.get Co-Authored-By: Claude Sonnet 4.5 --- packages/core/src/__tests__/session-manager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 39eb1cbb9..ae9e1ef45 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -6,7 +6,7 @@ import { setupTestContext, teardownTestContext, makeHandle, type TestContext } f // Mock child_process module with custom promisify vi.mock("node:child_process", () => { - const execFileMock = vi.fn(); + const execFileMock = vi.fn() as any; // Implement custom promisify to return { stdout, stderr } objects execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => { return new Promise((resolve, reject) => { @@ -51,7 +51,7 @@ beforeEach(() => { if (slot === "agent" && name === "opencode") { return opencodeAgent; } - return originalGet(slot, name); + return (originalGet as any)(slot, name); }); // Set project to use opencode agent From d36c0947cf39b06f9e81c9b1d6130012d5ccd2ee Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 13:53:30 +0530 Subject: [PATCH 16/19] refactor: deduplicate plugin resolution logic between core and CLI Export isPluginModule, normalizeImportedPluginModule, resolvePackageExportsEntry, and resolveLocalPluginEntrypoint from @composio/ao-core and import them in the CLI instead of maintaining independent copies that have already diverged. Removes ~70 lines of duplicated code from plugin-marketplace.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/lib/plugin-marketplace.ts | 81 +--------------------- packages/core/src/index.ts | 8 ++- packages/core/src/plugin-registry.ts | 8 +-- 3 files changed, 14 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 8b15fa917..45e899b73 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -1,9 +1,9 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core"; +import { resolveLocalPluginEntrypoint, type InstalledPluginConfig, type PluginSlot } from "@composio/ao-core"; const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[]; @@ -21,7 +21,6 @@ export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePlu export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL = "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json"; -const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const; const MARKETPLACE_CACHE_FILE = "plugin-registry.json"; function isPluginSlot(value: unknown): value is PluginSlot { @@ -125,81 +124,7 @@ export async function refreshMarketplaceCatalog( return mergedEntries; } -function isPluginModule(value: unknown): value is PluginModule { - if (!value || typeof value !== "object") return false; - const candidate = value as Partial; - return Boolean(candidate.manifest && typeof candidate.create === "function"); -} - -export function normalizeImportedPluginModule(value: unknown): PluginModule | null { - if (isPluginModule(value)) return value; - if (value && typeof value === "object" && "default" in value) { - const defaultExport = (value as { default?: unknown }).default; - if (isPluginModule(defaultExport)) return defaultExport; - } - return null; -} - -function resolvePackageExportsEntry(exportsField: unknown): string | null { - if (typeof exportsField === "string") return exportsField; - if (!exportsField || typeof exportsField !== "object") return null; - - const exportsRecord = exportsField as Record; - const dotEntry = exportsRecord["."]; - if (typeof dotEntry === "string") return dotEntry; - if (dotEntry && typeof dotEntry === "object") { - const importEntry = (dotEntry as Record)["import"]; - if (typeof importEntry === "string") return importEntry; - const defaultEntry = (dotEntry as Record)["default"]; - if (typeof defaultEntry === "string") return defaultEntry; - } - - const importEntry = exportsRecord["import"]; - if (typeof importEntry === "string") return importEntry; - - const defaultEntry = exportsRecord["default"]; - if (typeof defaultEntry === "string") return defaultEntry; - - return null; -} - -function resolveLocalPluginEntrypoint(pluginPath: string): string | null { - if (!existsSync(pluginPath)) return null; - - const stat = statSync(pluginPath); - if (stat.isFile()) return pluginPath; - if (!stat.isDirectory()) return null; - - const packageJsonPath = join(pluginPath, "package.json"); - if (existsSync(packageJsonPath)) { - try { - const raw = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(raw) as { exports?: unknown; main?: unknown; module?: unknown }; - const exportsEntry = resolvePackageExportsEntry(packageJson.exports); - if (exportsEntry) { - const resolvedExportsEntry = resolve(pluginPath, exportsEntry); - if (existsSync(resolvedExportsEntry)) return resolvedExportsEntry; - } - if (typeof packageJson.module === "string") { - const moduleEntry = resolve(pluginPath, packageJson.module); - if (existsSync(moduleEntry)) return moduleEntry; - } - if (typeof packageJson.main === "string") { - const mainEntry = resolve(pluginPath, packageJson.main); - if (existsSync(mainEntry)) return mainEntry; - } - } catch { - // Fall through to common entrypoints below. - } - } - - for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) { - const entry = join(pluginPath, candidate); - if (existsSync(entry)) return entry; - } - - return null; -} +export { normalizeImportedPluginModule } from "@composio/ao-core"; export function isLocalPluginReference(reference: string): boolean { return ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b54decd8..b8f4f58b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,13 @@ export { } from "./config.js"; // Plugin registry -export { createPluginRegistry } from "./plugin-registry.js"; +export { + createPluginRegistry, + isPluginModule, + normalizeImportedPluginModule, + resolveLocalPluginEntrypoint, + resolvePackageExportsEntry, +} from "./plugin-registry.js"; // Metadata — flat-file session metadata read/write export { diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 72e0783c5..0d3e9c20c 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -84,13 +84,13 @@ function extractPluginConfig( return undefined; } -function isPluginModule(value: unknown): value is PluginModule { +export function isPluginModule(value: unknown): value is PluginModule { if (!value || typeof value !== "object") return false; const candidate = value as Partial; return Boolean(candidate.manifest && typeof candidate.create === "function"); } -function normalizeImportedPluginModule(value: unknown): PluginModule | null { +export function normalizeImportedPluginModule(value: unknown): PluginModule | null { if (isPluginModule(value)) return value; if (value && typeof value === "object" && "default" in value) { @@ -107,7 +107,7 @@ function resolveConfigRelativePath(targetPath: string, configPath?: string): str return resolve(baseDir, targetPath); } -function resolvePackageExportsEntry(exportsField: unknown): string | null { +export function resolvePackageExportsEntry(exportsField: unknown): string | null { if (typeof exportsField === "string") return exportsField; if (!exportsField || typeof exportsField !== "object") return null; @@ -131,7 +131,7 @@ function resolvePackageExportsEntry(exportsField: unknown): string | null { return null; } -function resolveLocalPluginEntrypoint(pluginPath: string): string | null { +export function resolveLocalPluginEntrypoint(pluginPath: string): string | null { if (!existsSync(pluginPath)) return null; const stat = statSync(pluginPath); From 6cfb1cc864cd90514f525c8a802581545d1c4263 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 14:37:52 +0530 Subject: [PATCH 17/19] fix: guard statSync against permission errors, add fetch timeout - Wrap statSync in try/catch in resolveLocalPluginEntrypoint so permission-denied errors return null instead of crashing - Add 30s AbortSignal timeout to refreshMarketplaceCatalog fetch to prevent indefinite hangs on slow networks Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/lib/plugin-marketplace.ts | 2 +- packages/core/src/plugin-registry.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 45e899b73..9d5a0de03 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -112,7 +112,7 @@ export function loadMarketplaceCatalog(): MarketplacePluginEntry[] { export async function refreshMarketplaceCatalog( url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL, ): Promise { - const response = await fetch(url); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`); } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 0d3e9c20c..3153ceb5c 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -134,15 +134,16 @@ export function resolvePackageExportsEntry(exportsField: unknown): string | null export function resolveLocalPluginEntrypoint(pluginPath: string): string | null { if (!existsSync(pluginPath)) return null; - const stat = statSync(pluginPath); - if (stat.isFile()) { - return pluginPath; - } - - if (!stat.isDirectory()) { + let stat; + try { + stat = statSync(pluginPath); + } catch { return null; } + if (stat.isFile()) return pluginPath; + if (!stat.isDirectory()) return null; + const packageJsonPath = join(pluginPath, "package.json"); if (existsSync(packageJsonPath)) { try { From 166ace7477fd75dcdfc408b8f845d13650060021 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 14:38:28 +0530 Subject: [PATCH 18/19] refactor: extract fetch timeout into named constant Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/lib/plugin-marketplace.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 9d5a0de03..2aa49df1e 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -22,6 +22,7 @@ export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL = "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json"; const MARKETPLACE_CACHE_FILE = "plugin-registry.json"; +const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000; function isPluginSlot(value: unknown): value is PluginSlot { return ( @@ -112,7 +113,7 @@ export function loadMarketplaceCatalog(): MarketplacePluginEntry[] { export async function refreshMarketplaceCatalog( url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL, ): Promise { - const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + const response = await fetch(url, { signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) }); if (!response.ok) { throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`); } From 66aa92aec072cc68a517f5222b4288af9ec78fff Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:13:49 +0530 Subject: [PATCH 19/19] fix: use pnpm dev in monorepo to start terminal WebSocket servers (#846) In dev mode, `npx next dev` only starts Next.js without the terminal WebSocket servers. Detect monorepo by checking for `server/` dir and use `pnpm run dev` which runs both via concurrently. --- packages/cli/src/commands/dashboard.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cd61ef62a..8f494c77d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig } from "@composio/ao-core"; @@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void { config.directTerminalPort, ); - const child = spawn("npx", ["next", "dev", "-p", String(port)], { - cwd: webDir, - stdio: ["inherit", "inherit", "pipe"], - env, - }); + // In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND + // the terminal WebSocket servers via concurrently. Without the WS servers, + // the live terminal in the dashboard won't work. + const isDevMode = existsSync(resolve(webDir, "server")); + const child = isDevMode + ? spawn("pnpm", ["run", "dev"], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }) + : spawn("npx", ["next", "dev", "-p", String(port)], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }); const stderrChunks: string[] = [];