Files
paseo/packages/server/src/server/agent/mcp-server.test.ts
T
Mohamed BoudraandGitHub 20385bdb50 Add opt-in browser tools for desktop tabs
* Add opt-in browser tools for desktop tabs

Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs.

* Fix browser tools CI expectations

* Address browser tools review findings

* Restrict browser file automation paths

* Fix browser upload test on Windows

* Harden browser navigation inputs

* Make browser tools create usable tabs

* Update browser MCP empty-state test

* Fail browser tab creation when registration times out

* Fix browser screenshots for agents

* Hide disabled browser tools from agents

* Address browser tools architecture review

* Replace browser tools review tests

* Wrap browser tab registration errors

* Mock Expo Router in app unit tests

* Handle invalid browser automation requests

* Return browser failure on desktop disconnect

* Update browser disconnect websocket test

* Relax browser timeout polling test

* Handle invalid browser responses

* Return browser failure when send fails

* Remove local diagnostics and fixture paths

* Fix dev service home fallback

* Use worktree home for dev services

* Use managed daemon in desktop dev

* fix(browser): keep agent tabs addressable

Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution.

* refactor(browser): register tools through catalog

Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter.

* fix(settings): translate browser tools host error
2026-06-30 22:33:18 +02:00

4450 lines
147 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { realpathSync, rmSync } from "node:fs";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import Ajv from "ajv";
import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
import {
AgentListItemPayloadSchema,
AgentPermissionRequestPayloadSchema,
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import type {
CreateScheduleInput,
StoredSchedule,
UpdateScheduleInput,
} from "@getpaseo/protocol/schedule/types";
import type { ScheduleService } from "../schedule/service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
interface LooseSafeParseResult {
success: boolean;
data: unknown;
error: {
issues: Array<{ path: Array<string | number>; message: string; code: string }>;
};
}
interface LooseInputSchema {
safeParseAsync(input: unknown): Promise<LooseSafeParseResult>;
}
interface LooseStructuredContent {
[key: string]: unknown;
}
interface RegisteredMcpTool {
inputSchema: LooseInputSchema;
outputSchema?: unknown;
callback?: (
input: unknown,
extra?: unknown,
) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
handler?: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
}
interface RegisteredMcpToolWithHandler extends RegisteredMcpTool {
handler: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
}
function lookupTool(
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
name: string,
): RegisteredMcpTool | undefined {
const tools: Record<string, RegisteredMcpTool> = Reflect.get(server, "_registeredTools");
return tools[name];
}
function registeredTool(
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
name: string,
): RegisteredMcpToolWithHandler {
const tool = lookupTool(server, name);
if (!tool) {
throw new Error(`MCP tool not registered: ${name}`);
}
const handler = tool.handler ?? tool.callback;
if (!handler) {
throw new Error(`MCP tool has no callable handler: ${name}`);
}
return { ...tool, handler };
}
async function invokeToolWithParsedInput(
tool: RegisteredMcpToolWithHandler,
input: Record<string, unknown>,
) {
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
return tool.handler(parsed.data);
}
function expectOutputSchemaAccepts(tool: RegisteredMcpTool, data: unknown): void {
expect(tool.outputSchema).toBeDefined();
const jsonSchema = z.toJSONSchema(tool.outputSchema as z.ZodType, {
target: "draft-07",
unrepresentable: "any",
io: "input",
});
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(jsonSchema);
expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true);
}
function agentsOf(response: {
structuredContent: LooseStructuredContent;
}): Array<Record<string, unknown>> {
return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents);
}
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
interface TestDeps {
agentManager: AgentManager;
agentStorage: AgentStorage;
spies: {
agentManager: AgentManagerSpies;
agentStorage: AgentStorageSpies;
};
}
function buildAgentManagerSpies() {
return {
createAgent: vi.fn(),
waitForAgentEvent: vi.fn().mockResolvedValue({
status: "idle",
permission: null,
lastMessage: null,
}),
setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
notifyAgentState: vi.fn(),
getAgent: vi.fn(),
listAgents: vi.fn().mockReturnValue([]),
getTimeline: vi.fn().mockReturnValue([]),
resumeAgentFromPersistence: vi.fn(),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
waitForAgentRunStart: vi.fn().mockResolvedValue(undefined),
respondToPermission: vi.fn(),
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
listDraftFeatures: vi.fn(),
};
}
function buildAgentStorageSpies() {
return {
get: vi.fn().mockResolvedValue(null),
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn().mockResolvedValue([]),
remove: vi.fn(),
};
}
function createTestDeps(): TestDeps {
const agentManagerSpies = buildAgentManagerSpies();
const agentStorageSpies = buildAgentStorageSpies();
return {
agentManager: agentManagerSpies as unknown as AgentManager,
agentStorage: agentStorageSpies as unknown as AgentStorage,
spies: {
agentManager: agentManagerSpies,
agentStorage: agentStorageSpies,
},
};
}
function createTerminalManagerStub(overrides: Partial<TerminalManager> = {}): TerminalManager {
return {
getTerminals: vi.fn().mockResolvedValue([]),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(),
getTerminalState: vi.fn().mockResolvedValue(null),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn().mockResolvedValue(undefined),
captureTerminal: vi.fn().mockResolvedValue({ lines: [], totalLines: 0 }),
listDirectories: vi.fn().mockReturnValue([]),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn().mockReturnValue(() => {}),
subscribeTerminalWorkspaceContributionChanged: vi.fn().mockReturnValue(() => {}),
...overrides,
} as unknown as TerminalManager;
}
type ProviderSnapshotManagerStub = ReturnType<typeof createProviderSnapshotManagerStub>;
interface ConfigureProviderEntry {
provider: AgentProvider;
label?: string;
description?: string;
enabled?: boolean;
defaultModeId?: string;
modes?: AgentMode[];
}
// Builds a ProviderSnapshotEntry for tests that need to configure listProviders /
// getProvider directly. Mirrors the shape MCP server reads from the manager:
// status: "ready" for enabled+available, "unavailable" for disabled.
function buildSnapshotEntry(entry: ConfigureProviderEntry): ProviderSnapshotEntry {
const enabled = entry.enabled ?? true;
if (!enabled) {
return {
provider: entry.provider,
status: "unavailable",
enabled: false,
...(entry.label !== undefined ? { label: entry.label } : {}),
...(entry.description !== undefined ? { description: entry.description } : {}),
...(entry.defaultModeId !== undefined ? { defaultModeId: entry.defaultModeId } : {}),
modes: [],
};
}
return {
provider: entry.provider,
status: "ready",
enabled: true,
...(entry.label !== undefined ? { label: entry.label } : {}),
...(entry.description !== undefined ? { description: entry.description } : {}),
...(entry.defaultModeId !== undefined ? { defaultModeId: entry.defaultModeId } : {}),
modes: entry.modes ?? [],
};
}
// Shared helper used by ~60 create_agent / update_agent / list_agents tests that
// only need a "normal" provider catalog (claude, codex, opencode). OpenCode
// create-config behavior delegates to the production provider client.
//
// NOTE: This is NOT a registry. It directly configures the public stub surface.
// Per-test customization is done by overriding individual stub methods after
// calling this helper.
interface ConfigureOpenCodeProviderStubOptions {
customOpenCodeProvider?: AgentProvider;
}
function configureOpenCodeProviderStub(
stub: ProviderSnapshotManagerStub,
options: ConfigureOpenCodeProviderStubOptions = {},
): void {
const claudeModes: AgentMode[] = [
{ id: "default", label: "Default", description: "Ask first" },
{ id: "bypassPermissions", label: "Bypass", description: "No prompts", isUnattended: true },
];
const codexModes: AgentMode[] = [
{ id: "default", label: "Default", description: "Default" },
{ id: "auto", label: "Auto", description: "Auto" },
{ id: "full-access", label: "Full Access", description: "No prompts", isUnattended: true },
];
const opencodeModes: AgentMode[] = [
{ id: "build", label: "Build", description: "Can edit" },
{ id: "plan", label: "Plan", description: "Read-only" },
{ id: "paseo-custom", label: "Paseo Custom", description: "Custom OpenCode agent" },
];
const entries: ProviderSnapshotEntry[] = [
buildSnapshotEntry({
provider: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: claudeModes,
}),
buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI Codex",
defaultModeId: "default",
modes: codexModes,
}),
buildSnapshotEntry({
provider: "opencode",
label: "OpenCode",
description: "OpenCode agent",
defaultModeId: "build",
modes: opencodeModes,
}),
];
const customOpenCodeModes: AgentMode[] = [
...opencodeModes,
{ id: "paseo-custom", label: "Paseo Custom" },
];
if (options.customOpenCodeProvider) {
entries.push(
buildSnapshotEntry({
provider: options.customOpenCodeProvider,
label: "OpenCode Custom",
description: "Custom OpenCode agent",
defaultModeId: "build",
modes: customOpenCodeModes,
}),
);
}
const modesByProvider: Record<string, AgentMode[]> = {
claude: claudeModes,
codex: codexModes,
opencode: opencodeModes,
};
if (options.customOpenCodeProvider) {
modesByProvider[options.customOpenCodeProvider] = customOpenCodeModes;
}
stub.listRegisteredProviderIds.mockReturnValue(entries.map((entry) => entry.provider));
stub.hasProvider.mockImplementation((provider) =>
Object.prototype.hasOwnProperty.call(modesByProvider, provider),
);
stub.getProviderLabel.mockImplementation((provider) => {
const entry = entries.find((e) => e.provider === provider);
return entry?.label ?? provider;
});
stub.listProviders.mockImplementation(async (input) => {
const opts = (input ?? {}) as { providers?: AgentProvider[] };
if (!opts.providers) return entries;
const filter = new Set(opts.providers);
return entries.filter((e) => filter.has(e.provider));
});
stub.getProvider.mockImplementation(async (input) => {
const opts = input as { provider: AgentProvider };
const entry = entries.find((e) => e.provider === opts.provider);
if (!entry) throw new Error(`Provider ${opts.provider} is not configured`);
return entry;
});
stub.listModels.mockResolvedValue([]);
stub.listModes.mockImplementation(async (input) => {
const opts = input as { provider: AgentProvider };
return modesByProvider[opts.provider] ?? [];
});
stub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as {
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
};
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
});
}
// Quick helper: returns a manager configured with the standard OpenCode catalog.
function createOpenCodeManager(options?: ConfigureOpenCodeProviderStubOptions): {
manager: ProviderSnapshotManagerStub["manager"];
stub: ProviderSnapshotManagerStub;
} {
const stub = createProviderSnapshotManagerStub();
configureOpenCodeProviderStub(stub, options);
return { manager: stub.manager, stub };
}
// Quick helper: returns a bare stub manager seam. Use when the test does not
// care about provider behavior at all (terminal tests, schema-only tests,
// stored-agent listing tests where the stored agent's provider just needs to
// be in listRegisteredProviderIds).
function createClaudeOnlyManager(): ProviderSnapshotManagerStub["manager"] {
const stub = createProviderSnapshotManagerStub();
stub.listRegisteredProviderIds.mockReturnValue(["claude"]);
stub.hasProvider.mockImplementation((provider) => provider === "claude");
return stub.manager;
}
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
const now = "2026-04-11T00:00:00.000Z";
return {
id: "stored-agent",
provider: "claude",
cwd: "/tmp/stored-project",
createdAt: now,
updatedAt: now,
lastActivityAt: now,
lastUserMessageAt: null,
title: "Stored agent",
labels: {},
lastStatus: "closed",
lastModeId: "default",
config: {
modeId: "default",
model: "claude-sonnet-4-20250514",
},
runtimeInfo: {
provider: "claude",
sessionId: "session-123",
model: "claude-sonnet-4-20250514",
},
features: [],
persistence: {
provider: "claude",
sessionId: "session-123",
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: "2026-04-12T00:00:00.000Z",
...overrides,
};
}
function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent {
const now = new Date();
return {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
...overrides,
} as ManagedAgent;
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createStoredSchedule(input: CreateScheduleInput): StoredSchedule {
const now = "2026-04-11T00:00:00.000Z";
return {
id: "schedule-1",
name: input.name ?? null,
prompt: input.prompt,
cadence: input.cadence,
target: input.target,
status: "active",
createdAt: now,
updatedAt: now,
nextRunAt: now,
lastRunAt: null,
pausedAt: null,
expiresAt: input.expiresAt ?? null,
maxRuns: input.maxRuns ?? null,
runs: [],
};
}
function createArchiveWorkspaceRecordMutator(
activeWorkspaces: Array<{
workspaceId: string;
cwd: string;
kind: "worktree" | "local_checkout" | "directory";
}>,
archivedWorkspaceIds: string[],
) {
return async (workspaceId: string) => {
archivedWorkspaceIds.push(workspaceId);
const index = activeWorkspaces.findIndex((workspace) => workspace.workspaceId === workspaceId);
if (index !== -1) {
activeWorkspaces.splice(index, 1);
}
};
}
function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
createdWorkspaceIds?: string[];
setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: options.paseoHome,
deps: { github },
});
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const result = await createPaseoWorktreeService(input, {
github,
...(serviceOptions?.resolveDefaultBranch
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
},
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
});
options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") {
return {
...result,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
options.startedAgentSetupIds?.push(agentId);
},
},
};
}
return result;
};
}
describe("browser MCP tools", () => {
const logger = createTestLogger();
it("keeps browser tools registered when browser tools are disabled", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ id: "agent-1", cwd: REPO_CWD });
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-disabled",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
});
const tool = registeredTool(server, "browser_list_tabs");
const response = await tool.handler({});
expect(lookupTool(server, "browser_page_info")).not.toBeUndefined();
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: REPO_CWD,
command: { command: "list_tabs", args: { workspaceId: REPO_CWD } },
});
expect(response.structuredContent).toEqual({
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: REPO_CWD },
});
});
it("wires browser tools through the browser tools broker", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ id: "agent-1", cwd: REPO_CWD });
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-tabs",
ok: true,
result: { command: "list_tabs", tabs: [] },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
});
const tool = registeredTool(server, "browser_list_tabs");
const response = await tool.handler({});
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: REPO_CWD,
command: { command: "list_tabs", args: { workspaceId: REPO_CWD } },
});
expect(response.content).toEqual([
{
type: "text",
text: "No Paseo browser tabs are open. Call browser_new_tab to create one, then use the returned browserId or omit browserId for the active tab.",
},
]);
expect(response.structuredContent).toEqual({
ok: true,
result: { command: "list_tabs", tabs: [] },
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: REPO_CWD },
});
});
});
describe("terminal MCP tools", () => {
const logger = createTestLogger();
it("captures terminal output through the terminal manager authority", async () => {
const { agentManager, agentStorage } = createTestDeps();
const captureTerminal = vi.fn().mockResolvedValue({
lines: ["from worker scrollback"],
totalLines: 42,
});
const terminalManager = createTerminalManagerStub({
getTerminal: vi.fn().mockReturnValue({
id: "term-1",
name: "daemon",
cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}),
captureTerminal,
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
terminalManager,
logger,
});
const tool = registeredTool(server, "capture_terminal");
const response = await tool.handler({
terminalId: "term-1",
scrollback: true,
stripAnsi: false,
start: -10,
end: -1,
});
expect(captureTerminal).toHaveBeenCalledWith("term-1", {
start: 0,
end: -1,
stripAnsi: false,
});
expect(response.structuredContent).toEqual({
terminalId: "term-1",
lines: ["from worker scrollback"],
totalLines: 42,
});
});
});
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
const detachedDirectoryWorkspace = (path = existingCwd) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "directory" as const, path } },
});
const detachedWorktreeWorkspace = (
cwd: string,
target:
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number },
) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } },
});
const subagentCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "subagent" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const detachedCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const detachedExistingWorkspace = (workspaceId: string, cwd?: string) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "existing" as const, workspaceId, ...(cwd ? { cwd } : {}) },
});
const ensureWorkspaceForCreate = async () => "workspace-created";
it("requires a concise title no longer than 60 characters", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
expect(tool).toBeDefined();
const missingTitle = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(missingTitle.success).toBe(false);
expect(missingTitle.error.issues[0].path).toEqual(["title"]);
const tooLong = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "x".repeat(61),
initialPrompt: "test",
});
expect(tooLong.success).toBe(false);
expect(tooLong.error.issues[0].path).toEqual(["title"]);
const ok = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
initialPrompt: "test",
});
expect(ok.success).toBe(true);
});
it("requires initialPrompt", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
});
expect(parsed.success).toBe(false);
expect(
parsed.error.issues.some(
(issue: { path: Array<string | number> }) => issue.path[0] === "initialPrompt",
),
).toBe(true);
});
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
relationship: { kind: "detached" },
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(parsed.success).toBe(true);
await expect(tool.handler(parsed.data)).rejects.toThrow(
"relationship and workspace must be provided together",
);
});
it("rejects caller-only relationship and workspace intents without a caller agent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("relationship subagent requires an agent-scoped tool session");
await expect(
tool.handler({
...detachedCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("workspace current requires an agent-scoped tool session");
});
it("requires a caller workspace for current workspace intent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
}),
).rejects.toThrow("Caller agent parent-agent has no current workspace");
});
it("attaches create_agent to an existing workspace id", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "existing-workspace-agent",
cwd: existingCwd,
workspaceId: "wks_existing",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Existing workspace" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
listActiveWorkspaces: async () => [
{ workspaceId: "wks_existing", cwd: existingCwd, kind: "worktree" },
],
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...detachedExistingWorkspace("wks_existing"),
title: "Existing workspace",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(response.structuredContent.workspaceId).toBe("wks_existing");
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
}),
undefined,
{ workspaceId: "wks_existing" },
);
});
it("accepts provider features and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "feature-agent",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Feature test", featureValues: { fast_mode: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const input = {
...detachedDirectoryWorkspace(existingCwd),
title: "Feature test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
settings: { features: { fast_mode: true } },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
await tool.handler(input);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("advertises create_agent output schema that accepts full provider modes", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "mode-agent",
provider: "codex",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: "build",
availableModes: [
{
id: "build",
label: "Build",
description: null,
icon: "hammer",
colorTier: "dangerous",
},
],
config: { title: "Mode test" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Mode test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("requires provider as provider/model and rejects the old model field", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const missingProvider = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
initialPrompt: "test",
});
expect(missingProvider.success).toBe(false);
expect(
missingProvider.error.issues.some(
(issue: { path: Array<string | number> }) => issue.path[0] === "provider",
),
).toBe(true);
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex",
initialPrompt: "test",
});
expect(providerWithoutModel.success).toBe(false);
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex/",
initialPrompt: "test",
});
expect(providerWithEmptyModel.success).toBe(false);
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "/gpt-5.4",
initialPrompt: "test",
});
expect(providerWithEmptyProvider.success).toBe(false);
await expect(
tool.handler({
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex/gpt-5.4",
model: "gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("Unrecognized key");
});
it("accepts worktree workspace intent in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedWorktreeWorkspace(existingCwd, {
kind: "checkout-pr",
githubPrNumber: 42,
}),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target kind", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
}
});
it("rejects create_worktree without a target", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({});
expect(parsed.success).toBe(false);
});
it("surfaces createAgent validation failures", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockRejectedValue(
new Error("Working directory does not exist: /path/that/does/not/exist"),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...detachedDirectoryWorkspace("/path/that/does/not/exist"),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
}),
).rejects.toThrow("Working directory does not exist");
});
it("passes caller-provided titles directly into createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Fix auth bug" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: " Fix auth bug ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
title: "Fix auth bug",
}),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("trims caller-provided titles before createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Fix auth" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: " Fix auth ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
title: "Fix auth",
}),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("requires provider/model and passes thinking and labels through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Config test", model: "claude-sonnet-4-20250514" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Config test",
initialPrompt: "Do work",
provider: "codex/gpt-5.4",
settings: { modeId: "auto", thinkingOptionId: "think-hard" },
labels: { source: "mcp" },
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
title: "Config test",
provider: "codex",
model: "gpt-5.4",
thinkingOptionId: "think-hard",
}),
undefined,
{ labels: { source: "mcp" }, workspaceId: "workspace-created" },
);
});
it("registers and broadcasts a workspace when create_agent creates a worktree", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
const startedAgentSetupIds: string[] = [];
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
setupContinuations,
startedAgentSetupIds,
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
worktreeSlug: "agent-worktree",
branchName: "feature/agent-worktree",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(broadcasts).toHaveLength(1);
expect(createdWorkspaceIds).toHaveLength(1);
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(setupContinuations).toEqual(["agent"]);
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("feature/agent-worktree");
// The agent is stamped with the freshly created worktree's workspaceId so
// workspaceId-scoped archive can find and tear it down later.
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: expect.stringContaining("agent-worktree"),
}),
undefined,
{ workspaceId: createdWorkspaceIds[0] },
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "existing-feature"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Checkout agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "checkout-branch",
branch: "existing-feature",
}),
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const startedAgentSetupIds: string[] = [];
const createPaseoWorktree = vi.fn(
async (
input: CreatePaseoWorktreeInput,
options?: Parameters<CreatePaseoWorktreeWorkflowFn>[1],
) => ({
worktree: {
branchName: "pr-123",
worktreePath: "/tmp/worktrees/pr-123",
},
intent: {
kind: "checkout-github-pr" as const,
githubPrNumber: input.githubPrNumber ?? 123,
headRef: "pr-123",
baseRefName: "main",
},
workspace: {
workspaceId: "ws-pr-123",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
displayName: "pr-123",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: REPO_CWD,
created: true,
...(options?.setupContinuation?.kind === "agent"
? {
setupContinuation: {
kind: "agent" as const,
startAfterAgentCreate: ({ agentId }: { agentId: string }) => {
startedAgentSetupIds.push(agentId);
},
},
}
: {}),
}),
);
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-pr-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "PR agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
createPaseoWorktree,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(REPO_CWD, {
kind: "checkout-pr",
githubPrNumber: 123,
}),
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
background: true,
});
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
githubPrNumber: 123,
firstAgentContext: { prompt: "Rename this PR branch from prompt" },
}),
expect.objectContaining({
setupContinuation: expect.objectContaining({ kind: "agent" }),
}),
);
expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
undefined,
{ workspaceId: "ws-pr-123" },
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
setupContinuations,
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.handler({
cwd: repoDir,
target: {
kind: "branch-off",
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
},
});
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:create-worktree",
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const archiveWorkspaceRecord = vi.fn(async () => undefined);
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async () => undefined);
const markWorkspaceArchiving = vi.fn();
const clearWorkspaceArchiving = vi.fn();
const listActiveWorkspaces = vi.fn(async () => []);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
listActiveWorkspaces,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving,
clearWorkspaceArchiving,
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
listActiveWorkspaces.mockImplementation(async () => [
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
]);
archiveWorkspaceRecord.mockImplementation(async () => {
listActiveWorkspaces.mockResolvedValueOnce([]);
});
workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
["ws-archive-tool-worktree"],
expect.any(String),
);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]);
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
"ws-archive-tool-worktree",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("archives every workspace on a directory and removes the directory", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const archivedWorkspaceIds: string[] = [];
let activeWorkspaces: Array<{
workspaceId: string;
cwd: string;
kind: "worktree" | "local_checkout" | "directory";
}> = [];
const listActiveWorkspaces = vi.fn(async () => activeWorkspaces);
const archiveWorkspaceRecord = createArchiveWorkspaceRecordMutator(
activeWorkspaces,
archivedWorkspaceIds,
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
findWorkspaceIdForCwd: vi.fn(async () => "ws-mcp-A"),
listActiveWorkspaces,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
// Populate the active workspaces with the real created path so archiveByScope
// matches it against the worktree directory.
activeWorkspaces = [
{ workspaceId: "ws-mcp-A", cwd: worktreePath, kind: "worktree" as const },
{ workspaceId: "ws-mcp-B", cwd: worktreePath, kind: "worktree" as const },
];
await archiveTool.handler({
cwd: repoDir,
worktreePath,
});
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("archives a worktree by slug", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
listActiveWorkspaces: vi.fn(async () => []),
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
});
const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});
expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("routes list_worktrees through WorkspaceGitService", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => [
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
]),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "list_worktrees");
const response = await tool.handler({ cwd: REPO_CWD });
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
]);
});
it("accepts custom provider IDs in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
title: "Custom provider agent",
settings: { modeId: "default" },
provider: "zai/custom-model",
initialPrompt: "Do work",
});
expect(parsed.success).toBe(true);
});
it("allows caller agents to override cwd and applies caller context labels", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
const subdir = join(baseDir, "subdir");
await mkdir(subdir, { recursive: true });
spies.agentManager.getAgent.mockReturnValue({
id: "voice-agent",
cwd: baseDir,
workspaceId: "wks_voice",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: subdir,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "voice-agent",
resolveCallerContext: () => ({
childAgentDefaultLabels: { source: "voice" },
allowCustomCwd: true,
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace("subdir"),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: subdir,
}),
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: "voice-agent",
source: "voice",
},
workspaceId: "wks_voice",
},
);
await rm(baseDir, { recursive: true, force: true });
});
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: false,
}),
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected caller create_agent input to parse");
}
expect(parsed.data).toMatchObject({
relationship: { kind: "subagent" },
workspace: { kind: "current" },
notifyOnFinish: true,
});
});
it("returns notify-on-finish guidance for caller-created agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
spies.agentManager.createAgent.mockResolvedValue(childAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(response.structuredContent.guidance).toBe(
"You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
);
});
it("creates detached caller agents without a parent label", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "detached-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Detached" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedCurrentWorkspace(),
title: "Detached",
provider: "codex/gpt-5.4",
initialPrompt: "Take over",
labels: {
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
source: "handoff",
},
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
}),
undefined,
{
labels: {
source: "handoff",
},
workspaceId: "wks_parent",
},
);
});
it("accepts provider features from caller agents and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child", featureValues: { fast_mode: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { featureValues: Record<string, unknown> | undefined };
return { modeId: undefined, featureValues: opts.featureValues };
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
callerAgentId: "parent-agent",
providerSnapshotManager: providerSnapshot.manager,
logger,
});
const tool = registeredTool(server, "create_agent");
const input = {
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
settings: { features: { fast_mode: true } },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
await tool.handler(input);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
}),
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
workspaceId: "wks_parent",
},
);
});
it("inherits the parent's workspaceId when an MCP child is created in the parent's working tree", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-workspace-inherit-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
try {
const parent = await agentManager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
const result = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
const childId = z.object({ agentId: z.string() }).parse(result.structuredContent).agentId;
const storedChild = await storage.get(childId);
expect(storedChild?.workspaceId).toBe("wks_parent");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Injected config test" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Injected config test",
settings: { modeId: "auto" },
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
const [configArg, agentIdArg, optionsArg] = spies.agentManager.createAgent.mock.calls[0];
expect(configArg).toMatchObject({
cwd: existingCwd,
title: "Injected config test",
});
expect(configArg.mcpServers).toBeUndefined();
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toEqual({ workspaceId: "workspace-created" });
});
it("rejects an explicit mode that is not valid for the target provider", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async () => {
throw new Error("resolver rejected mode");
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Bad mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "bypassPermissions" },
initialPrompt: "Do work",
}),
).rejects.toThrow("resolver rejected mode");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
});
it("validates create_agent modes against the shared provider snapshot", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: "dynamic",
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const dynamicModes: AgentMode[] = [
{ id: "dynamic", label: "Dynamic", description: "Runtime mode" },
];
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({ provider: "codex", label: "Codex", modes: dynamicModes }),
]);
provStub.getProvider.mockImplementation(async ({ provider }: { provider: AgentProvider }) =>
buildSnapshotEntry({ provider, label: "Codex", modes: dynamicModes }),
);
provStub.listModes.mockResolvedValue(dynamicModes);
provStub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { requestedMode: string | undefined };
expect(opts.requestedMode).toBe("dynamic");
return { modeId: "dynamic", featureValues: undefined };
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Dynamic mode",
provider: "codex/gpt-5.4",
settings: { modeId: "dynamic" },
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "dynamic" }),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("passes resolver-returned mode and features into createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Child", featureValues: { auto_accept: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "build",
featureValues: { auto_accept: true },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
title: "Legacy mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "full-access" },
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("passes the real parent agent and explicit unattended intent to the resolver", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent;
spies.agentManager.getAgent.mockReturnValue(parentAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: "resolver-mode",
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
callerAgentId: "parent-agent",
providerSnapshotManager: providerSnapshot.manager,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work",
});
expect(providerSnapshot.stub.resolveCreateConfig).toHaveBeenCalledWith(
expect.objectContaining({ parent: parentAgent, unattended: false }),
);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
}),
undefined,
expect.any(Object),
);
});
it("accepts an explicit valid mode across providers", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "opencode/gpt-5.4",
settings: { modeId: "build" },
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build" }),
undefined,
expect.any(Object),
);
});
});
describe("send_agent_prompt MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
it("defaults agent-scoped prompts to background finish notifications", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "running",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected caller send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: true,
notifyOnFinish: true,
});
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
);
});
it("keeps top-level prompts blocking by default", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected top-level send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: false,
notifyOnFinish: false,
});
await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
it("does not arm a finish notification for blocking agent-scoped prompts", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: "child-agent",
prompt: "Follow up",
background: false,
});
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
});
describe("update_agent MCP tool", () => {
const logger = createTestLogger();
it("does not register the replaced feature-specific MCP tool", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
expect(lookupTool(server, "set_agent_feature")).toBeUndefined();
});
it("updates runtime settings before metadata", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "update_agent");
const input = {
agentId: "agent-1",
name: "Updated agent",
labels: { role: "worker" },
settings: {
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
const response = await tool.handler(input);
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent",
labels: { role: "worker" },
});
expect(response.structuredContent).toEqual({ success: true });
});
it("reports success for a no-op update with neither metadata nor settings", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "update_agent");
const response = await tool.handler({ agentId: "agent-1" });
expect(response.structuredContent).toEqual({ success: true });
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
});
it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "update_agent");
await expect(
tool.handler({
agentId: "agent-1",
name: "Should not persist",
labels: { role: "worker" },
settings: { features: { fast_mode: true } },
}),
).rejects.toThrow("unsupported feature");
expect(spies.agentStorage.get).not.toHaveBeenCalled();
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
});
});
describe("create_schedule MCP tool", () => {
const logger = createTestLogger();
it("requires provider for schedules", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
expect(create).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
provider: "codex",
});
await tool.handler({
prompt: "say hello again",
cron: "*/10 * * * *",
provider: "codex/gpt-5.4",
});
expect(create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
target: {
type: "new-agent",
config: {
provider: "codex",
cwd: process.cwd(),
},
},
}),
);
expect(create).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
target: {
type: "new-agent",
config: {
provider: "codex",
cwd: process.cwd(),
model: "gpt-5.4",
},
},
}),
);
});
it("advertises create_schedule output schema that accepts inherited feature values", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
provider: "opencode",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: {
title: "Parent agent",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
},
} as ManagedAgent);
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_schedule");
const response = await tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
provider: "opencode/openai/gpt-5.5",
});
expect(response.structuredContent.target).toMatchObject({
type: "new-agent",
config: { featureValues: { auto_accept: true } },
});
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("passes timezone through cron create_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, {
prompt: "say hello",
cron: "0 9 * * 1-5",
timezone: " America/New_York ",
provider: "codex",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence: {
type: "cron",
expression: "0 9 * * 1-5",
timezone: "America/New_York",
},
}),
);
});
it("rejects removed create_schedule every input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
const parsed = await tool.inputSchema.safeParseAsync({
prompt: "say hello",
every: "10m",
provider: "codex",
});
expect(parsed.success).toBe(false);
expect(create).not.toHaveBeenCalled();
});
it("rejects create_schedule without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
tool.handler({
prompt: "say hello",
provider: "codex",
}),
).rejects.toThrow(/cron/);
expect(create).not.toHaveBeenCalled();
});
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
prompt: "say hello",
cron: "0 9 * * 1-5",
timezone,
provider: "codex",
}),
).rejects.toThrow();
expect(create).not.toHaveBeenCalled();
});
});
describe("create_heartbeat MCP tool", () => {
const logger = createTestLogger();
it("creates a self-targeted cron heartbeat", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
provider: "codex",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Parent agent" },
} as ManagedAgent);
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_heartbeat");
await invokeToolWithParsedInput(tool, {
prompt: "check status",
cron: "*/15 * * * *",
timezone: "America/New_York",
name: "status heartbeat",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "check status",
cadence: {
type: "cron",
expression: "*/15 * * * *",
timezone: "America/New_York",
},
target: { type: "agent", agentId: "parent-agent" },
name: "status heartbeat",
}),
);
});
it("requires an agent-scoped session", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_heartbeat");
await expect(
tool.handler({
prompt: "check status",
cron: "*/15 * * * *",
}),
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
expect(create).not.toHaveBeenCalled();
});
});
describe("update_schedule MCP tool", () => {
const logger = createTestLogger();
function makeStoredSchedule(): StoredSchedule {
return {
id: "schedule-1",
name: "test schedule",
prompt: "say hello",
cadence: { type: "every", everyMs: 300000 },
target: { type: "new-agent", config: { provider: "claude", cwd: "/tmp" } },
status: "active",
createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: "2026-04-11T00:00:00.000Z",
nextRunAt: "2026-04-11T00:05:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
};
}
it("calls scheduleService.update with correct input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => ({
...stored,
name: "updated name",
prompt: "new prompt",
updatedAt: "2026-04-11T01:00:00.000Z",
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await tool.handler({
id: "schedule-1",
name: "updated name",
prompt: "new prompt",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
name: "updated name",
prompt: "new prompt",
});
});
it("converts every to cadence", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await tool.handler({
id: "schedule-1",
every: "10m",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
});
});
it("passes timezone through cron update_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, {
id: "schedule-1",
cron: "0 9 * * 1-5",
timezone: "Europe/Zurich",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: {
type: "cron",
expression: "0 9 * * 1-5",
timezone: "Europe/Zurich",
},
});
});
it("accepts a blank cron field when updating every cadence", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, {
id: "schedule-1",
every: "10m",
cron: "",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
});
});
it.each([
{
label: "whitespace cron field",
input: { id: "schedule-1", every: "10m", cron: " " },
cadence: { type: "every", everyMs: 600000 },
},
{
label: "blank every field for cron cadence",
input: { id: "schedule-1", every: "", cron: "*/10 * * * *" },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "whitespace every field for cron cadence",
input: { id: "schedule-1", every: " ", cron: "*/10 * * * *" },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
])("normalizes update_schedule blank cadence input for $label", async ({ input, cadence }) => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, input);
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence,
});
});
it("rejects both every and cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
tool.handler({
id: "schedule-1",
every: "5m",
cron: "* * * * *",
}),
).rejects.toThrow("Specify at most one of every or cron");
expect(update).not.toHaveBeenCalled();
});
it("rejects update_schedule timezone without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
invokeToolWithParsedInput(tool, {
id: "schedule-1",
every: "10m",
timezone: "Europe/Zurich",
}),
).rejects.toThrow("timezone can only be used with cron");
expect(update).not.toHaveBeenCalled();
});
it.each(["", " "])("rejects update_schedule blank timezone %#", async (timezone) => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
invokeToolWithParsedInput(tool, {
id: "schedule-1",
cron: "0 9 * * 1-5",
timezone,
}),
).rejects.toThrow();
expect(update).not.toHaveBeenCalled();
});
it("passes new-agent config and expiry updates", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await tool.handler({
id: "schedule-1",
provider: "codex/gpt-5.4",
mode: "full-access",
cwd: "/home/user/project",
expiresIn: "1h",
});
const updateInput = update.mock.calls[0]?.[0];
expect(updateInput).toMatchObject({
id: "schedule-1",
newAgentConfig: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
cwd: "/home/user/project",
},
});
expect(updateInput?.expiresAt).toEqual(expect.any(String));
});
it("clears model, mode, max runs, and expiry", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await tool.handler({
id: "schedule-1",
model: null,
mode: null,
maxRuns: null,
clearExpires: true,
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
maxRuns: null,
expiresAt: null,
newAgentConfig: {
model: null,
modeId: null,
},
});
});
it("rejects conflicting model and expiry inputs", async () => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
tool.handler({
id: "schedule-1",
provider: "codex/gpt-5.4",
model: "gpt-5.5",
}),
).rejects.toThrow("Conflicting model values provided");
await expect(
tool.handler({
id: "schedule-1",
expiresIn: "1h",
clearExpires: true,
}),
).rejects.toThrow("Specify at most one of expiresIn or clearExpires");
expect(update).not.toHaveBeenCalled();
});
});
describe("schedule_logs MCP tool", () => {
const logger = createTestLogger();
function makeRun(overrides: Partial<{ id: string; status: string }> = {}) {
return {
id: overrides.id ?? "run-1",
scheduledFor: "2026-04-11T00:00:00.000Z",
startedAt: "2026-04-11T00:00:01.000Z",
endedAt: "2026-04-11T00:00:05.000Z",
status: overrides.status ?? "succeeded",
agentId: null,
output: "done",
error: null,
};
}
it("returns runs for a schedule", async () => {
const { agentManager, agentStorage } = createTestDeps();
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
const logs = vi.fn(async (_id: string) => runs);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { logs } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "schedule_logs");
const result = await tool.handler({ id: "schedule-1" });
expect(logs).toHaveBeenCalledWith("schedule-1");
expect(result.structuredContent).toEqual({ runs });
});
it("throws when schedule service is not configured", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "schedule_logs");
await expect(tool.handler({ id: "schedule-1" })).rejects.toThrow(
"Schedule service is not configured",
);
});
});
describe("provider listing MCP tool", () => {
const logger = createTestLogger();
it("returns providers from the registry, including custom providers", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["claude", "zai"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "claude",
label: "Claude",
description: "Test provider",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
}),
buildSnapshotEntry({
provider: "zai" as AgentProvider,
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
{
id: "claude",
label: "Claude",
description: "Test provider",
enabled: true,
status: "available",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
{
id: "zai",
label: "ZAI",
status: "available",
description: "Custom Claude profile",
enabled: true,
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns provider modes from the shared snapshot catalog", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent.providers).toEqual([
expect.objectContaining({
id: "codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
});
it("returns disabled providers with metadata without checking availability", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
{
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
status: "unavailable",
modes: [],
},
],
});
});
});
describe("provider MCP tools", () => {
const logger = createTestLogger();
it("does not register the replaced feature-specific provider discovery MCP tool", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
});
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
});
provStub.listProviders.mockResolvedValue([codexEntry]);
provStub.getProvider.mockResolvedValue(codexEntry);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
const input = {
provider: "codex/gpt-5.4",
cwd: "~/repo",
settings: {
modeId: "full-access",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
provider: "codex",
cwd: expect.stringContaining("repo"),
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: true,
status: "available",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
selectedModel: "gpt-5.4",
features: [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
],
});
});
it("rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listModels.mockRejectedValue(new Error("Provider 'codex' is disabled"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_models");
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
it("inspect_provider rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.getProvider.mockResolvedValue(
buildSnapshotEntry({ provider: "codex", label: "Codex", enabled: false }),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
});
describe("speak MCP tool", () => {
const logger = createTestLogger();
it("invokes registered speak handler for caller agent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const speak = vi.fn().mockResolvedValue(undefined);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "voice-agent-1",
enableVoiceTools: true,
resolveSpeakHandler: () => speak,
logger,
});
const tool = registeredTool(server, "speak");
expect(tool).toBeDefined();
await tool.handler({ text: "Hello from voice agent." });
expect(speak).toHaveBeenCalledWith(
expect.objectContaining({
text: "Hello from voice agent.",
callerAgentId: "voice-agent-1",
}),
);
});
it("fails when no speak handler exists", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "voice-agent-2",
enableVoiceTools: true,
resolveSpeakHandler: () => null,
logger,
});
const tool = registeredTool(server, "speak");
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for your session",
);
});
it("does not register speak tool unless voice tools are enabled", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "agent-no-voice",
logger,
});
const tool = lookupTool(server, "speak");
expect(tool).toBeUndefined();
});
});
describe("agent snapshot MCP serialization", () => {
const logger = createTestLogger();
it("returns compact list items from list_agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listAgents = vi.fn().mockReturnValue([
createManagedAgent({
id: "agent-compact",
provider: "codex",
cwd: REPO_CWD,
config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
labels: { role: "researcher" },
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({});
const structured = z
.object({ agents: z.array(z.record(z.string(), z.unknown())) })
.parse(response.structuredContent);
expect(structured).toEqual({
agents: [
{
id: "agent-compact",
shortId: "agent-c",
title: null,
provider: "codex",
model: "gpt-5.4",
thinkingOptionId: "high",
effectiveThinkingOptionId: "high",
status: "idle",
cwd: REPO_CWD,
createdAt: expect.any(String),
updatedAt: expect.any(String),
lastUserMessageAt: null,
archivedAt: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
labels: { role: "researcher" },
},
],
});
expect(structured.agents[0]).not.toHaveProperty("features");
expect(structured.agents[0]).not.toHaveProperty("availableModes");
expect(structured.agents[0]).not.toHaveProperty("capabilities");
expect(structured.agents[0]).not.toHaveProperty("runtimeInfo");
expect(structured.agents[0]).not.toHaveProperty("persistence");
expect(structured.agents[0]).not.toHaveProperty("pendingPermissions");
});
it("returns archived agent snapshots from storage for get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
});
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(record);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_status");
const response = await tool.handler({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({
status: "closed",
snapshot: expect.objectContaining({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
title: "Stored agent",
status: "closed",
}),
});
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
});
it("returns full-detail snapshots from get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentStorage.get.mockResolvedValue({ title: "Full detail agent" });
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "full-detail-agent",
provider: "codex",
cwd: "/tmp/full-detail",
config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: {
provider: "codex",
sessionId: "session-full",
model: "gpt-5.4",
thinkingOptionId: "xhigh",
modeId: "auto",
},
currentModeId: "auto",
availableModes: [
{
id: "auto",
label: "Auto",
description: "Default coding mode",
},
],
features: [
{
type: "toggle",
id: "web-search",
label: "Web search",
value: true,
},
],
pendingPermissions: new Map(),
persistence: {
provider: "codex",
sessionId: "session-full",
},
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsSessionListing: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsNewProviderCapability: true,
},
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "get_agent_status");
const response = await tool.handler({ agentId: "full-detail-agent" });
const snapshot = z.record(z.string(), z.unknown()).parse(response.structuredContent.snapshot);
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
if (!parsed.success) {
throw new Error(
`get_agent_status response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
);
}
expectOutputSchemaAccepts(tool, response.structuredContent);
expect(response.structuredContent.status).toBe("idle");
expect(snapshot).toEqual(
expect.objectContaining({
id: "full-detail-agent",
title: "Full detail agent",
provider: "codex",
model: "gpt-5.4",
thinkingOptionId: "high",
effectiveThinkingOptionId: "xhigh",
currentModeId: "auto",
runtimeInfo: {
provider: "codex",
sessionId: "session-full",
model: "gpt-5.4",
thinkingOptionId: "xhigh",
modeId: "auto",
},
persistence: {
provider: "codex",
sessionId: "session-full",
},
}),
);
expect(snapshot.capabilities).toEqual(
expect.objectContaining({
supportsMcpServers: true,
supportsToolInvocations: true,
}),
);
expect(snapshot.availableModes).toEqual([
{
id: "auto",
label: "Auto",
description: "Default coding mode",
},
]);
expect(snapshot.features).toEqual([
{
type: "toggle",
id: "web-search",
label: "Web search",
value: true,
},
]);
expect(snapshot.pendingPermissions).toEqual([]);
});
it("does not expose internal stored agents from get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(
createStoredRecord({
id: "internal-agent",
internal: true,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_status");
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found",
);
});
it("defaults list_agents to caller cwd and excludes archived agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({ id: "caller-agent", cwd: "/tmp/workspace" }),
);
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({ id: "in-cwd", cwd: "/tmp/workspace" }),
createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }),
createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }),
]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({
id: "stored-in-cwd",
cwd: "/tmp/workspace",
updatedAt: now,
lastActivityAt: now,
archivedAt: null,
}),
createStoredRecord({
id: "archived-in-cwd",
cwd: "/tmp/workspace",
updatedAt: now,
lastActivityAt: now,
archivedAt: now,
}),
createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
callerAgentId: "caller-agent",
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({});
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(3);
expect(new Set(agentIds)).toEqual(new Set(["in-cwd", "in-child-cwd", "stored-in-cwd"]));
});
it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = Date.now();
const recent = new Date(now - 60 * 60 * 1000).toISOString();
const old = new Date(now - 72 * 60 * 60 * 1000).toISOString();
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
id: "running-target",
cwd: TARGET_CWD,
lifecycle: "running",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "idle-target",
cwd: TARGET_CWD,
lifecycle: "idle",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "old-running-target",
cwd: TARGET_CWD,
lifecycle: "running",
createdAt: new Date(old),
updatedAt: new Date(old),
}),
]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({
id: "recent-other-cwd",
cwd: resolvePath("/tmp/other"),
archivedAt: recent,
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({
cwd: TARGET_CWD,
includeArchived: true,
sinceHours: 48,
statuses: ["running", "closed"],
limit: 3,
});
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
"running-target",
"old-running-target",
"recent-archived",
]);
});
it("bounds includeArchived by default time window and limit", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = Date.now();
const recentArchivedRecords = Array.from({ length: 55 }, (_, index) =>
createStoredRecord({
id: `recent-archived-${index.toString().padStart(2, "0")}`,
archivedAt: new Date(now - index * 60 * 1000).toISOString(),
}),
);
spies.agentStorage.list.mockResolvedValue([
...recentArchivedRecords,
createStoredRecord({
id: "old-archived",
archivedAt: new Date(now - 49 * 60 * 60 * 1000).toISOString(),
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ includeArchived: true });
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(50);
expect(agentIds).toEqual(
Array.from(
{ length: 50 },
(_, index) => `recent-archived-${index.toString().padStart(2, "0")}`,
),
);
expect(agentIds).not.toContain("old-archived");
});
it("returns compact list items for stored archived agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({
id: "stored-archived-compact",
cwd: REPO_CWD,
updatedAt: now,
lastActivityAt: now,
archivedAt: now,
features: [
{
type: "toggle",
id: "danger-zone",
label: "Danger zone",
value: false,
},
],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
const item = agentsOf(response)[0];
expect(item).toEqual({
id: "stored-archived-compact",
shortId: "stored-",
title: "Stored agent",
provider: "claude",
model: "claude-sonnet-4-20250514",
thinkingOptionId: null,
effectiveThinkingOptionId: null,
status: "closed",
cwd: REPO_CWD,
createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: now,
lastUserMessageAt: null,
archivedAt: now,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
labels: {},
});
expect(item).not.toHaveProperty("features");
expect(item).not.toHaveProperty("availableModes");
expect(item).not.toHaveProperty("capabilities");
expect(item).not.toHaveProperty("runtimeInfo");
expect(item).not.toHaveProperty("persistence");
expect(item).not.toHaveProperty("pendingPermissions");
});
it("sorts list_agents by attention, status priority, then activity", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = Date.now();
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
id: "idle-recent",
lifecycle: "idle",
updatedAt: new Date(now),
}),
createManagedAgent({
id: "running-older",
lifecycle: "running",
updatedAt: new Date(now - 60 * 60 * 1000),
}),
createManagedAgent({
id: "closed-newest",
lifecycle: "closed",
updatedAt: new Date(now + 60 * 1000),
}),
createManagedAgent({
id: "initializing-middle",
lifecycle: "initializing",
updatedAt: new Date(now - 30 * 60 * 1000),
}),
createManagedAgent({
id: "idle-attention-oldest",
lifecycle: "idle",
updatedAt: new Date(now - 2 * 60 * 60 * 1000),
attention: {
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date(now - 2 * 60 * 60 * 1000),
},
}),
createManagedAgent({
id: "error-recent",
lifecycle: "error",
updatedAt: new Date(now),
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({});
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
"idle-attention-oldest",
"running-older",
"initializing-middle",
"idle-recent",
"error-recent",
"closed-newest",
]);
});
it("emits list_agents payloads that satisfy the declared output schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({
id: "stored-non-archived",
updatedAt: now,
lastActivityAt: now,
archivedAt: null,
}),
createStoredRecord({ id: "stored-archived", archivedAt: now }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ includeArchived: true });
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) {
throw new Error(
`list_agents response failed AgentListItemPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
);
}
});
it("emits list_pending_permissions payloads that satisfy the declared output schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
id: "agent-with-permission",
provider: "codex",
pendingPermissions: new Map([
[
"perm-minimal",
{
id: "perm-minimal",
provider: "codex",
name: "request_user_input",
kind: "question",
title: "Need input",
input: { prompt: "Pick one" },
detail: undefined,
metadata: { source: "test" },
},
],
]),
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "list_pending_permissions");
const response = await tool.handler({});
const permissions = z
.array(
z.object({
agentId: z.string(),
status: z.string(),
request: AgentPermissionRequestPayloadSchema,
}),
)
.parse(response.structuredContent.permissions);
expect(permissions).toEqual([
{
agentId: "agent-with-permission",
status: "idle",
request: {
id: "perm-minimal",
provider: "codex",
name: "request_user_input",
kind: "question",
title: "Need input",
input: { prompt: "Pick one" },
metadata: { source: "test" },
},
},
]);
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("loads archived agents before reading get_agent_activity", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({ id: "archived-activity-agent" });
const snapshot = {
id: "archived-activity-agent",
currentModeId: "default",
} as ManagedAgent;
spies.agentManager.getAgent
.mockReturnValueOnce(null)
.mockReturnValue(snapshot)
.mockReturnValue(snapshot);
spies.agentStorage.get.mockResolvedValue(record);
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
spies.agentManager.getTimeline.mockReturnValue([
{
kind: "status",
timestamp: "2026-04-11T00:00:00.000Z",
text: "Agent resumed",
},
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
agentId: "archived-activity-agent",
updateCount: 1,
currentModeId: "default",
}),
);
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
"archived-activity-agent",
);
});
it("get_agent_activity limit counts projected messages, not raw deltas", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const snapshot = createManagedAgent({ id: "live-activity-agent", currentModeId: "default" });
spies.agentManager.getAgent.mockReturnValue(snapshot);
spies.agentManager.getTimeline.mockReturnValue([
{ type: "user_message", text: "Say hi" },
{ type: "assistant_message", text: "Hello " },
{ type: "assistant_message", text: "world" },
{ type: "assistant_message", text: "." },
{ type: "assistant_message", text: " How" },
{ type: "assistant_message", text: " are" },
{ type: "assistant_message", text: " you?" },
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger: createTestLogger(),
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
const content = String(response.structuredContent.content);
expect(content).toContain("Hello world. How are you?");
});
it("get_agent_activity limit=2 returns the last two projected entries whole", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const snapshot = createManagedAgent({ id: "live-activity-agent-2", currentModeId: "default" });
spies.agentManager.getAgent.mockReturnValue(snapshot);
spies.agentManager.getTimeline.mockReturnValue([
{ type: "user_message", text: "u1" },
{ type: "assistant_message", text: "first " },
{ type: "assistant_message", text: "answer" },
{ type: "user_message", text: "u2" },
{ type: "assistant_message", text: "second " },
{ type: "assistant_message", text: "answer" },
{ type: "user_message", text: "u3" },
{ type: "assistant_message", text: "third " },
{ type: "assistant_message", text: "answer" },
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger: createTestLogger(),
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
const content = String(response.structuredContent.content);
expect(content).toContain("[User] u3");
expect(content).toContain("third answer");
expect(content).not.toContain("[User] u2");
expect(content).not.toContain("second answer");
expect(content).not.toContain("first answer");
});
});