mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering. Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering. All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven. Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change. Closes #6137. Closes #6147.
6936 lines
233 KiB
TypeScript
6936 lines
233 KiB
TypeScript
import {
|
|
createFinishSubscriptions,
|
|
createFinishObservation,
|
|
createJsonFinishSubscriptionStore,
|
|
type FinishSubscriptionStore,
|
|
type FinishSubscriptions,
|
|
type FinishSubscription,
|
|
} from "./finish-subscriptions/index.js";
|
|
import type { AgentMessageDelivery } from "./agent-messages/index.js";
|
|
import { createTestAgentMessages } from "../test-utils/agent-messages.js";
|
|
import { type PromptDispatchDisposition } from "./agent-prompt.js";
|
|
import { parseAgentMessage, parseAgentMessages } from "./agent-messages/index.js";
|
|
import { execFileSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { realpathSync } from "node:fs";
|
|
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join, resolve as resolvePath } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { z } from "zod";
|
|
|
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
|
import { createAgentMcpServer as createAgentMcpServerWithDependencies } 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 { OmpAgentClient } from "./providers/omp/agent.js";
|
|
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
|
|
import type {
|
|
AgentClient,
|
|
AgentMode,
|
|
AgentPermissionRequest,
|
|
AgentPromptInput,
|
|
AgentProvider,
|
|
AgentRunResult,
|
|
AgentSession,
|
|
AgentStreamEvent,
|
|
ProviderSnapshotEntry,
|
|
} from "./agent-sdk-types.js";
|
|
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
|
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
|
import {
|
|
AgentListItemPayloadSchema,
|
|
AgentPermissionRequestPayloadSchema,
|
|
AgentSnapshotPayloadSchema,
|
|
} from "@getpaseo/protocol/messages";
|
|
import {
|
|
createPersistedProjectRecord,
|
|
createPersistedWorkspaceRecord,
|
|
type PersistedProjectRecord,
|
|
type PersistedWorkspaceRecord,
|
|
type ProjectRegistry,
|
|
type WorkspaceRegistry,
|
|
} 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 {
|
|
createPaseoWorktreeWorkflow,
|
|
type CreatePaseoWorktreeWorkflowFn,
|
|
} from "../worktree-session.js";
|
|
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
|
import { WorkspaceAutoName } from "../workspace-auto-name.js";
|
|
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
|
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
|
import type { ForgeService } from "../../services/forge-service.js";
|
|
import { areEquivalentPaths } from "../../utils/path.js";
|
|
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
|
|
import { startWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
|
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
|
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
|
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
|
|
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
|
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
|
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
|
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
|
|
|
const REPO_CWD = resolvePath("/tmp/repo");
|
|
const TARGET_CWD = resolvePath("/tmp/target");
|
|
const BROWSER_WORKSPACE_ID = "wks_browser_tools";
|
|
|
|
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 LooseContentBlock {
|
|
type: string;
|
|
text?: string;
|
|
data?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
interface RegisteredMcpTool {
|
|
inputSchema: LooseInputSchema;
|
|
callback?: (
|
|
input: unknown,
|
|
extra?: unknown,
|
|
) => Promise<{
|
|
structuredContent: LooseStructuredContent;
|
|
content?: LooseContentBlock[];
|
|
}>;
|
|
handler?: (input: unknown) => Promise<{
|
|
structuredContent: LooseStructuredContent;
|
|
content?: LooseContentBlock[];
|
|
}>;
|
|
}
|
|
|
|
interface RegisteredMcpToolWithHandler extends RegisteredMcpTool {
|
|
handler: (input: unknown) => Promise<{
|
|
structuredContent: LooseStructuredContent;
|
|
content?: LooseContentBlock[];
|
|
}>;
|
|
}
|
|
|
|
const subscriptionServices: FinishSubscriptions[] = [];
|
|
afterEach(async () => {
|
|
for (const service of subscriptionServices.splice(0)) await service.close();
|
|
});
|
|
const serverSubscriptionRecords = new WeakMap<object, Map<string, FinishSubscription>>();
|
|
const serverSubscriptions = new WeakMap<object, FinishSubscriptions>();
|
|
const serverDeliveries = new WeakMap<object, AgentMessageDelivery>();
|
|
const toolDeliveries = new WeakMap<object, AgentMessageDelivery>();
|
|
|
|
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}`);
|
|
}
|
|
const registered = { ...tool, handler };
|
|
const delivery = serverDeliveries.get(server);
|
|
if (delivery) toolDeliveries.set(registered, delivery);
|
|
return registered;
|
|
}
|
|
|
|
async function invokeToolWithParsedInput(
|
|
tool: RegisteredMcpToolWithHandler,
|
|
input: Record<string, unknown>,
|
|
) {
|
|
const parsed = await tool.inputSchema.safeParseAsync(input);
|
|
expect(parsed.success).toBe(true);
|
|
const response = await tool.handler(parsed.data);
|
|
if (response.structuredContent.deliveryStatus === "queued") {
|
|
await toolDeliveries
|
|
.get(tool)!
|
|
.waitForDispatch(input.agentId as string, new AbortController().signal);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function sendMcpAndAwaitDispatch(
|
|
client: Client,
|
|
server: object,
|
|
input: Record<string, unknown>,
|
|
) {
|
|
const response = await client.callTool({ name: "send_agent_prompt", arguments: input });
|
|
await serverDeliveries
|
|
.get(server)!
|
|
.waitForDispatch(input.agentId as string, new AbortController().signal);
|
|
return response;
|
|
}
|
|
|
|
function agentsOf(response: {
|
|
structuredContent: LooseStructuredContent;
|
|
}): Array<Record<string, unknown>> {
|
|
return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents);
|
|
}
|
|
|
|
function expectSingleTextContent(response: { content?: LooseContentBlock[] }): string {
|
|
const content = response.content ?? [];
|
|
expect(content).toHaveLength(1);
|
|
const block = content[0];
|
|
expect(block?.type).toBe("text");
|
|
return z.string().min(1).parse(block?.text);
|
|
}
|
|
|
|
async function waitForWorkspaceTitle(
|
|
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
|
|
workspaceId: string,
|
|
title: string,
|
|
): Promise<void> {
|
|
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.title).toBe(title), {
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
|
|
async function removeTempDir(path: string): Promise<void> {
|
|
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
|
|
async function removeAgentStateDir(
|
|
agentManager: AgentManager,
|
|
storage: AgentStorage,
|
|
path: string,
|
|
): Promise<void> {
|
|
await agentManager.flush();
|
|
await storage.flush();
|
|
await removeTempDir(path);
|
|
}
|
|
|
|
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),
|
|
getPluginTools: () => [],
|
|
reloadStalePluginSession: async () => {},
|
|
hasInFlightRun: vi.fn().mockReturnValue(false),
|
|
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
|
|
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(): ForgeService {
|
|
return {
|
|
listPullRequests: async () => [],
|
|
listIssues: async () => [],
|
|
searchIssuesAndPrs: async () => ({
|
|
items: [],
|
|
featuresEnabled: true,
|
|
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}`,
|
|
getPullRequestCheckoutTarget: async ({ number }) => ({
|
|
number,
|
|
baseRefName: "main",
|
|
headRefName: `pr-${number}`,
|
|
headOwnerLogin: null,
|
|
headRepositorySshUrl: null,
|
|
headRepositoryUrl: null,
|
|
isCrossRepository: false,
|
|
}),
|
|
getCurrentPullRequestStatus: async () => null,
|
|
createPullRequest: async () => ({
|
|
number: 1,
|
|
url: "https://github.com/acme/repo/pull/1",
|
|
}),
|
|
mergePullRequest: async () => ({ success: true }),
|
|
isAuthenticated: async () => true,
|
|
invalidate: () => {},
|
|
};
|
|
}
|
|
|
|
class FakeBrowserToolsBroker {
|
|
public readonly calls: BrowserToolsExecuteInput[] = [];
|
|
|
|
public constructor(private readonly response: BrowserToolsResponsePayload) {}
|
|
|
|
public async execute(input: BrowserToolsExecuteInput): Promise<BrowserToolsResponsePayload> {
|
|
this.calls.push(input);
|
|
return this.response;
|
|
}
|
|
}
|
|
|
|
class BoundaryAgentManagerFake {
|
|
public getPluginTools(): [] {
|
|
return [];
|
|
}
|
|
|
|
private readonly agent = createManagedAgent({
|
|
id: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
});
|
|
|
|
public getAgent(agentId: string): ManagedAgent | null {
|
|
return agentId === this.agent.id ? this.agent : null;
|
|
}
|
|
|
|
public listAgents(): ManagedAgent[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
class BoundaryAgentStorageFake {
|
|
public async list(): Promise<StoredAgentRecord[]> {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
class BoundaryProviderSnapshotManagerFake {
|
|
public listRegisteredProviderIds(): AgentProvider[] {
|
|
return [];
|
|
}
|
|
|
|
public hasProvider(): boolean {
|
|
return false;
|
|
}
|
|
|
|
public getProviderLabel(provider: AgentProvider): string {
|
|
return provider;
|
|
}
|
|
|
|
public async listProviders(): Promise<ProviderSnapshotEntry[]> {
|
|
return [];
|
|
}
|
|
|
|
public async getProvider(): Promise<ProviderSnapshotEntry> {
|
|
throw new Error("Provider catalog is not used by this boundary test");
|
|
}
|
|
|
|
public async listModels(): Promise<[]> {
|
|
return [];
|
|
}
|
|
|
|
public async listModes(): Promise<[]> {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function connectInMemoryMcpClient(server: Awaited<ReturnType<typeof createAgentMcpServer>>) {
|
|
const client = new Client({ name: "paseo-test-client", version: "0.0.0" });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return client;
|
|
}
|
|
|
|
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[];
|
|
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
|
|
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
|
|
startedAgentSetupIds?: string[];
|
|
}): CreatePaseoWorktreeWorkflowFn {
|
|
const projects = new Map<string, PersistedProjectRecord>();
|
|
const workspaces = options.workspaceRecords ?? new Map<string, PersistedWorkspaceRecord>();
|
|
const github = createGitHubServiceStub();
|
|
const workspaceGitService = new WorkspaceGitServiceImpl({
|
|
logger: createTestLogger(),
|
|
paseoHome: options.paseoHome,
|
|
deps: { forgeOverrides: { github } },
|
|
});
|
|
const projectRegistry: ProjectRegistry = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(projects.values()),
|
|
get: async (projectId) => projects.get(projectId) ?? null,
|
|
getOrCreateActiveByRoot: async (allocation) => {
|
|
const existing = Array.from(projects.values()).find(
|
|
(project) =>
|
|
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
|
|
);
|
|
if (existing) return existing;
|
|
const project = createPersistedProjectRecord({
|
|
projectId: `prj_test_${projects.size + 1}`,
|
|
rootPath: allocation.rootPath,
|
|
kind: allocation.kind,
|
|
displayName: allocation.displayName,
|
|
createdAt: allocation.timestamp,
|
|
updatedAt: allocation.timestamp,
|
|
});
|
|
projects.set(project.projectId, project);
|
|
return project;
|
|
},
|
|
upsert: async (record) => {
|
|
projects.set(record.projectId, record);
|
|
},
|
|
archive: async (projectId, archivedAt) => {
|
|
const project = projects.get(projectId);
|
|
if (project) projects.set(projectId, { ...project, archivedAt });
|
|
},
|
|
remove: async (projectId) => {
|
|
projects.delete(projectId);
|
|
},
|
|
};
|
|
const workspaceRegistry: WorkspaceRegistry = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
|
list: async () => Array.from(workspaces.values()),
|
|
update: async (workspaceId, updater) => {
|
|
const workspace = workspaces.get(workspaceId);
|
|
if (!workspace) return null;
|
|
const updated = updater(workspace);
|
|
workspaces.set(workspaceId, updated);
|
|
return updated;
|
|
},
|
|
upsert: async (record: PersistedWorkspaceRecord) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
},
|
|
archive: async (workspaceId, archivedAt) => {
|
|
const workspace = workspaces.get(workspaceId);
|
|
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
|
},
|
|
remove: async (workspaceId) => {
|
|
workspaces.delete(workspaceId);
|
|
},
|
|
};
|
|
const workspaceProvisioning = createWorkspaceProvisioningService({
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
isDirectory: async () => true,
|
|
logger: createTestLogger(),
|
|
});
|
|
const workspaceAutoName = new WorkspaceAutoName({
|
|
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: createGitMutationService({
|
|
workspaceGitService,
|
|
github,
|
|
logger: createTestLogger(),
|
|
}),
|
|
emitWorkspaceUpdateForCwd: async (cwd) => {
|
|
const workspace = Array.from(workspaces.values()).find((record) => record.cwd === cwd);
|
|
options.broadcasts.push(z.string().parse(workspace?.workspaceId));
|
|
},
|
|
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
|
|
options.broadcasts.push(workspaceId);
|
|
},
|
|
logger: createTestLogger(),
|
|
generateWorkspaceName: options.generateWorkspaceName ?? (async () => null),
|
|
});
|
|
|
|
return async (input, serviceOptions) => {
|
|
const result = await createPaseoWorktreeWorkflow(
|
|
{
|
|
paseoHome: options.paseoHome,
|
|
createPaseoWorktree: (workflowInput, workflowOptions) =>
|
|
createPaseoWorktreeService(workflowInput, {
|
|
github,
|
|
...(workflowOptions?.resolveDefaultBranch
|
|
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
|
|
: {}),
|
|
workspaceGitService,
|
|
workspaceProvisioning,
|
|
}),
|
|
warmWorkspaceGitData: async () => {},
|
|
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
|
workspaceAutoName.scheduleForWorktree(autoNameInput),
|
|
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
|
|
options.broadcasts.push(workspaceId);
|
|
},
|
|
cacheWorkspaceSetupSnapshot: () => {},
|
|
emit: () => {},
|
|
sessionLogger: createTestLogger(),
|
|
terminalManager: null,
|
|
serviceProxy: null,
|
|
scriptRuntimeStore: null,
|
|
getDaemonTcpPort: null,
|
|
getDaemonTcpHost: null,
|
|
onScriptsChanged: null,
|
|
},
|
|
input,
|
|
serviceOptions,
|
|
);
|
|
options.broadcasts.push(result.workspace.workspaceId);
|
|
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
|
return result;
|
|
};
|
|
}
|
|
|
|
// Typed in-memory workspace port for catalogue tests without workspace setup.
|
|
async function createAgentMcpServer(
|
|
options: Parameters<typeof createAgentMcpServerWithDependencies>[0],
|
|
subscriptionStore?: FinishSubscriptionStore,
|
|
) {
|
|
const messages = createTestAgentMessages(options);
|
|
const records = new Map<string, FinishSubscription>();
|
|
const subscriptions = createFinishSubscriptions({
|
|
store: subscriptionStore ?? {
|
|
list: async () => [...records.values()],
|
|
put: async (record) => {
|
|
records.set(record.id, record);
|
|
},
|
|
remove: async (id) => {
|
|
records.delete(id);
|
|
},
|
|
},
|
|
messages: () => messages,
|
|
callerIsActive: async (id) => !(await options.agentStorage.get(id))?.archivedAt,
|
|
serverId: options.serverId ?? "self",
|
|
logger: options.logger,
|
|
...createFinishObservation({
|
|
...options,
|
|
waitForResume: async () => {},
|
|
messages: () => messages,
|
|
serverId: options.serverId ?? "self",
|
|
hosts: () => {
|
|
if (!options.hosts) throw new Error("No hosts");
|
|
return options.hosts();
|
|
},
|
|
}),
|
|
});
|
|
subscriptionServices.push(subscriptions);
|
|
const server = await createAgentMcpServerWithDependencies({
|
|
agentMessages: () => messages,
|
|
finishSubscriptions: () => subscriptions,
|
|
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
|
|
...options,
|
|
});
|
|
serverSubscriptionRecords.set(server, records);
|
|
serverSubscriptions.set(server, subscriptions);
|
|
serverDeliveries.set(server, messages);
|
|
return server;
|
|
}
|
|
|
|
describe("bundled Paseo skill", () => {
|
|
it("documents only advertised create_workspace input names", async () => {
|
|
const server = await createAgentMcpServer({
|
|
agentManager: new BoundaryAgentManagerFake() as AgentManager,
|
|
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
logger: createTestLogger(),
|
|
});
|
|
const client = await connectInMemoryMcpClient(server);
|
|
|
|
try {
|
|
const skill = await readFile(
|
|
new URL("../../../../../skills/paseo/SKILL.md", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const paragraph = skill.split("\n").find((line) => line.startsWith("**`create_workspace`**"));
|
|
expect(paragraph).toBeTypeOf("string");
|
|
// The final sentence describes output, rather than tool inputs.
|
|
const inputGuidance = paragraph!.split("Returns ")[0]!;
|
|
const documentedInputs = [...inputGuidance.matchAll(/`([a-zA-Z][a-zA-Z0-9]*)`/g)].map(
|
|
(match) => match[1]!,
|
|
);
|
|
expect(documentedInputs.length).toBeGreaterThan(0);
|
|
const { tools } = await client.listTools();
|
|
const tool = tools.find((entry) => entry.name === "create_workspace")!;
|
|
const properties = tool.inputSchema.properties!;
|
|
const advertisedNamesAndValues = new Set([
|
|
...Object.keys(properties),
|
|
...Object.values(properties).flatMap((property) =>
|
|
typeof property === "object" && property !== null && Array.isArray(property.enum)
|
|
? property.enum
|
|
: [],
|
|
),
|
|
]);
|
|
expect(documentedInputs.filter((name) => !advertisedNamesAndValues.has(name))).toEqual([]);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Paseo tool timeline presentation", () => {
|
|
it("catalogs a timeline label for every advertised tool", async () => {
|
|
const server = await createAgentMcpServer({
|
|
agentManager: new BoundaryAgentManagerFake() as AgentManager,
|
|
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: new FakeBrowserToolsBroker({
|
|
requestId: "req-catalog",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
}) as BrowserToolsBroker,
|
|
callerAgentId: "agent-1",
|
|
logger: createTestLogger(),
|
|
});
|
|
const client = await connectInMemoryMcpClient(server);
|
|
|
|
try {
|
|
const { tools } = await client.listTools();
|
|
const cataloged = new Set<string>(PASEO_TOOL_NAMES);
|
|
// speak renders as a voice message, not a tool call row.
|
|
const uncataloged = tools
|
|
.map((tool) => tool.name)
|
|
.filter((name) => name !== "speak" && !cataloged.has(name));
|
|
expect(tools.length).toBeGreaterThan(40);
|
|
expect(uncataloged).toEqual([]);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("browser MCP tools", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it("omits output schemas from tools/list and keeps tool call content model-visible", async () => {
|
|
const agentManager = new BoundaryAgentManagerFake();
|
|
const agentStorage = new BoundaryAgentStorageFake();
|
|
const broker = new FakeBrowserToolsBroker({
|
|
requestId: "req-browser-tabs",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
});
|
|
const serverOptions = {
|
|
agentManager: agentManager as AgentManager,
|
|
agentStorage: agentStorage as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: broker as BrowserToolsBroker,
|
|
callerAgentId: "agent-1",
|
|
logger,
|
|
};
|
|
const server = await createAgentMcpServer(serverOptions);
|
|
const client = await connectInMemoryMcpClient(server);
|
|
|
|
try {
|
|
const browserResult = await client.callTool({
|
|
name: "browser_list_tabs",
|
|
arguments: {},
|
|
});
|
|
const listAgentsResult = await client.callTool({
|
|
name: "list_agents",
|
|
arguments: {},
|
|
});
|
|
|
|
expect(broker.calls).toEqual([
|
|
{
|
|
agentId: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
command: { command: "list_tabs", args: {} },
|
|
},
|
|
]);
|
|
expect(browserResult.isError).not.toBe(true);
|
|
expect(browserResult.structuredContent).toEqual({
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
|
|
});
|
|
expect(listAgentsResult.isError).not.toBe(true);
|
|
expect(listAgentsResult.structuredContent).toEqual({
|
|
agents: [],
|
|
});
|
|
expectSingleTextContent(browserResult);
|
|
expect(expectSingleTextContent(listAgentsResult)).toContain('"agents": []');
|
|
|
|
const listedTools = await client.listTools();
|
|
expect(listedTools.tools.map((tool) => tool.name)).toEqual(
|
|
expect.arrayContaining(["browser_list_tabs", "list_agents"]),
|
|
);
|
|
for (const tool of listedTools.tools) {
|
|
expect(tool, `${tool.name} outputSchema`).not.toHaveProperty("outputSchema");
|
|
}
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
it("returns screenshot pixels as image content and keeps structured content metadata-only", async () => {
|
|
const agentManager = new BoundaryAgentManagerFake();
|
|
const agentStorage = new BoundaryAgentStorageFake();
|
|
const broker = new FakeBrowserToolsBroker({
|
|
requestId: "req-browser-screenshot",
|
|
ok: true,
|
|
result: {
|
|
command: "screenshot",
|
|
browserId: "11111111-1111-4111-8111-111111111111",
|
|
mimeType: "image/png",
|
|
dataBase64: "iVBORw0KGgo=",
|
|
width: 800,
|
|
height: 600,
|
|
},
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager: agentManager as AgentManager,
|
|
agentStorage: agentStorage as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: broker as BrowserToolsBroker,
|
|
callerAgentId: "agent-1",
|
|
logger,
|
|
});
|
|
const client = await connectInMemoryMcpClient(server);
|
|
|
|
try {
|
|
const response = await client.callTool({
|
|
name: "browser_screenshot",
|
|
arguments: { browserId: "11111111-1111-4111-8111-111111111111" },
|
|
});
|
|
|
|
expect(broker.calls).toEqual([
|
|
{
|
|
agentId: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
command: {
|
|
command: "screenshot",
|
|
args: {
|
|
browserId: "11111111-1111-4111-8111-111111111111",
|
|
fullPage: false,
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
expect(response.content).toEqual([
|
|
{ type: "text", text: "Captured browser screenshot (800x600)." },
|
|
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
|
]);
|
|
expect(response.structuredContent).toEqual({
|
|
ok: true,
|
|
result: {
|
|
command: "screenshot",
|
|
browserId: "11111111-1111-4111-8111-111111111111",
|
|
mimeType: "image/png",
|
|
width: 800,
|
|
height: 600,
|
|
},
|
|
context: {
|
|
agentId: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
browserId: "11111111-1111-4111-8111-111111111111",
|
|
},
|
|
});
|
|
expect(JSON.stringify(response.structuredContent)).not.toContain("iVBORw0KGgo=");
|
|
expect(JSON.stringify(response.structuredContent)).not.toContain("dataBase64");
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
it("does not register browser tools when browser tools are disabled", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
spies.agentManager.getAgent.mockReturnValue({
|
|
id: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
browserToolsEnabled: false,
|
|
callerAgentId: "agent-1",
|
|
logger,
|
|
});
|
|
|
|
const client = await connectInMemoryMcpClient(server);
|
|
try {
|
|
const listedTools = await client.listTools();
|
|
const toolNames = listedTools.tools.map((tool) => tool.name);
|
|
|
|
expect(toolNames).not.toContain("browser_list_tabs");
|
|
expect(toolNames).not.toContain("browser_snapshot");
|
|
expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "list_agents"]));
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
it("applies provider policy after the browser-tools host gate", async () => {
|
|
const agentManager = new BoundaryAgentManagerFake();
|
|
const agentStorage = new BoundaryAgentStorageFake();
|
|
const broker = new FakeBrowserToolsBroker({
|
|
requestId: "req-browser-policy",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager: agentManager as AgentManager,
|
|
agentStorage: agentStorage as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: broker as BrowserToolsBroker,
|
|
callerAgentId: "agent-1",
|
|
paseoToolPolicy: { disabledTools: ["browser_list_tabs"] },
|
|
logger,
|
|
});
|
|
|
|
expect(lookupTool(server, "browser_list_tabs")).toBeUndefined();
|
|
expect(lookupTool(server, "browser_snapshot")).toBeDefined();
|
|
});
|
|
|
|
it("filters policy-disabled tools from MCP listing and calls", async () => {
|
|
const agentManager = new BoundaryAgentManagerFake();
|
|
const agentStorage = new BoundaryAgentStorageFake();
|
|
const broker = new FakeBrowserToolsBroker({
|
|
requestId: "req-browser-policy-call",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager: agentManager as AgentManager,
|
|
agentStorage: agentStorage as AgentStorage,
|
|
providerSnapshotManager:
|
|
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: broker as BrowserToolsBroker,
|
|
callerAgentId: "agent-1",
|
|
paseoToolPolicy: { disabledTools: ["list_agents", "browser_list_tabs"] },
|
|
logger,
|
|
});
|
|
const client = await connectInMemoryMcpClient(server);
|
|
|
|
try {
|
|
const listedTools = await client.listTools();
|
|
const toolNames = listedTools.tools.map((tool) => tool.name);
|
|
|
|
expect(toolNames).not.toContain("list_agents");
|
|
expect(toolNames).not.toContain("browser_list_tabs");
|
|
expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "browser_snapshot"]));
|
|
await expect(client.callTool({ name: "list_agents", arguments: {} })).resolves.toEqual({
|
|
content: [{ type: "text", text: "MCP error -32602: Tool list_agents not found" }],
|
|
isError: true,
|
|
});
|
|
await expect(client.callTool({ name: "browser_list_tabs", arguments: {} })).resolves.toEqual({
|
|
content: [{ type: "text", text: "MCP error -32602: Tool browser_list_tabs not found" }],
|
|
isError: true,
|
|
});
|
|
expect(broker.calls).toEqual([]);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
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,
|
|
workspaceId: BROWSER_WORKSPACE_ID,
|
|
});
|
|
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,
|
|
browserToolsEnabled: true,
|
|
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: BROWSER_WORKSPACE_ID,
|
|
command: { command: "list_tabs", args: {} },
|
|
});
|
|
expect(response.content).toEqual([
|
|
{
|
|
type: "text",
|
|
text: "No Paseo browser tabs are open. Call browser_new_tab to create one.",
|
|
},
|
|
]);
|
|
expect(response.structuredContent).toEqual({
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
|
|
});
|
|
});
|
|
|
|
it("tells browser callers without a workspace how to proceed before broker execution", 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,
|
|
browserToolsEnabled: true,
|
|
browserToolsBroker: { execute } as never,
|
|
callerAgentId: "agent-1",
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "browser_list_tabs");
|
|
|
|
const response = await tool.handler({});
|
|
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(response.content).toEqual([
|
|
{
|
|
type: "text",
|
|
text: "This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs.",
|
|
},
|
|
]);
|
|
expect(response.structuredContent).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "browser_denied",
|
|
message:
|
|
"This browser tool needs a workspace. Start the agent from a Paseo workspace before calling browser_new_tab or browser_list_tabs.",
|
|
retryable: false,
|
|
},
|
|
context: { agentId: "agent-1", cwd: REPO_CWD },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("terminal MCP tools", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it.each<[string, string, string, boolean]>([
|
|
["Down", "\x1b[B", "", false],
|
|
["Up", "\x1b[A", "", false],
|
|
["Left", "\x1b[D", "", false],
|
|
["Right", "\x1b[C", "", false],
|
|
["PageUp", "\x1b[5~", "", false],
|
|
["PageDown", "\x1b[6~", "", false],
|
|
["Home", "\x1b[H", "", false],
|
|
["End", "\x1b[F", "", false],
|
|
["Insert", "\x1b[2~", "", false],
|
|
["Delete", "\x1b[3~", "", false],
|
|
["F1", "\x1bOP", "", false],
|
|
["F5", "\x1b[15~", "", false],
|
|
["F12", "\x1b[24~", "", false],
|
|
["S-Tab", "\x1b[Z", "", false],
|
|
["C-r", "\x12", "", false],
|
|
["M-b", "\x1bb", "", false],
|
|
["C-M-Left", "\x1b[1;7D", "", false],
|
|
["C-S-Up", "\x1b[1;6A", "", false],
|
|
["C-Space", "\x00", "", false],
|
|
["Enter", "\r", "", false],
|
|
["Tab", "\t", "", false],
|
|
["Escape", "\x1b", "", false],
|
|
["Space", " ", "", false],
|
|
["BSpace", "\x7f", "", false],
|
|
["C-c", "\x03", "", false],
|
|
["C-d", "\x04", "", false],
|
|
["C-z", "\x1a", "", false],
|
|
["C-l", "\x0c", "", false],
|
|
["C-a", "\x01", "", false],
|
|
["C-e", "\x05", "", false],
|
|
["Down", "\x1bOB", "\x1b[?1h", false],
|
|
["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false],
|
|
["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false],
|
|
["echo hello\r", "echo hello\r", "", false],
|
|
["\x1b[B", "\x1b[B", "", false],
|
|
["UnknownKey", "UnknownKey", "", false],
|
|
["constructor", "constructor", "", false],
|
|
["__proto__", "__proto__", "", false],
|
|
["", "", "", false],
|
|
["Down", "Down", "\x1b[?1h", true],
|
|
["C-r", "C-r", "", true],
|
|
["S-Tab", "S-Tab", "", true],
|
|
])(
|
|
"sends terminal token %j as %j (preamble=%j, literal=%s)",
|
|
async (keys, data, preamble, literal) => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const send = vi.fn();
|
|
const terminalManager = createTerminalManagerStub({
|
|
getTerminal: vi.fn().mockReturnValue({
|
|
send,
|
|
getReplayPreamble: () => preamble,
|
|
}),
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createClaudeOnlyManager(),
|
|
terminalManager,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const response = await registeredTool(server, "send_terminal_keys").handler({
|
|
terminalId: "term-1",
|
|
keys,
|
|
literal,
|
|
});
|
|
expect(response.structuredContent).toEqual({ success: true });
|
|
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data });
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["in-process", async () => ({ manager: createTerminalManager(), home: null })],
|
|
[
|
|
"worker",
|
|
async () => {
|
|
const home = await mkdtemp(join(tmpdir(), "mcp-terminal-keys-home-"));
|
|
const manager = await startWorkerTerminalManager({
|
|
paseoHome: home,
|
|
paseoVersion: "0.0.0-test",
|
|
logger,
|
|
});
|
|
return { manager, home };
|
|
},
|
|
],
|
|
] as const)(
|
|
"delivers MCP key tokens through %s after terminal input modes change",
|
|
async (_name, createManager) => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const { manager: terminalManager, home } = await createManager();
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd: process.cwd(),
|
|
workspaceId: "ws-terminal-keys",
|
|
command: process.execPath,
|
|
args: [
|
|
"-e",
|
|
`process.stdin.setRawMode(true);
|
|
process.stdout.write("READY\\r\\n");
|
|
process.stdin.on("data", data => {
|
|
if (data.toString() === "application") {
|
|
process.stdout.write("\\x1b[?1hAPPLICATION_READY\\r\\n");
|
|
return;
|
|
}
|
|
if (data.toString() === "normal") {
|
|
process.stdout.write("\\x1b[?1lNORMAL_READY\\r\\n");
|
|
return;
|
|
}
|
|
process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n");
|
|
});`,
|
|
],
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createClaudeOnlyManager(),
|
|
terminalManager,
|
|
logger,
|
|
});
|
|
|
|
// Every step is a round trip through a real PTY. A loaded Windows runner (ConPTY) has
|
|
// taken over 3 s to reach READY, so each step gets the same window rather than 1 s.
|
|
const roundTrip = { timeout: 5000 };
|
|
let output = "";
|
|
const unsubscribe = terminal.subscribe((message) => {
|
|
if (message.type === "output") {
|
|
output += message.data;
|
|
}
|
|
});
|
|
try {
|
|
await vi.waitFor(async () => {
|
|
const capture = await terminalManager.captureTerminal(terminal.id);
|
|
expect(capture.lines).toContain("READY");
|
|
}, roundTrip);
|
|
const tool = registeredTool(server, "send_terminal_keys");
|
|
await tool.handler({ terminalId: terminal.id, keys: "application", literal: true });
|
|
await vi.waitFor(() => expect(output).toContain("APPLICATION_READY"), roundTrip);
|
|
for (const [keys, hex] of [
|
|
["Down", "1b4f42"],
|
|
["Up", "1b4f41"],
|
|
["Left", "1b4f44"],
|
|
["Right", "1b4f43"],
|
|
["PageDown", "1b5b367e"],
|
|
["S-Tab", "1b5b5a"],
|
|
["C-r", "12"],
|
|
["M-b", "1b62"],
|
|
]) {
|
|
await tool.handler({ terminalId: terminal.id, keys });
|
|
await vi.waitFor(async () => {
|
|
const capture = await terminalManager.captureTerminal(terminal.id);
|
|
expect(capture.lines.join("\n")).toContain(`HEX:${hex}`);
|
|
}, roundTrip);
|
|
}
|
|
await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true });
|
|
await vi.waitFor(() => expect(output).toContain("NORMAL_READY"), roundTrip);
|
|
await tool.handler({ terminalId: terminal.id, keys: "Down" });
|
|
await vi.waitFor(async () => {
|
|
const capture = await terminalManager.captureTerminal(terminal.id);
|
|
expect(capture.lines).toContain("HEX:1b5b42");
|
|
}, roundTrip);
|
|
} finally {
|
|
unsubscribe();
|
|
await server.close();
|
|
await terminalManager.killTerminalAndWait(terminal.id);
|
|
terminalManager.killAll();
|
|
if (home) await rm(home, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
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(),
|
|
}),
|
|
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 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("creates a fresh local workspace for canonical top-level creation", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
spies.agentManager.createAgent.mockResolvedValue({
|
|
id: "top-level-agent",
|
|
provider: "codex",
|
|
cwd: existingCwd,
|
|
workspaceId: "workspace-created",
|
|
lifecycle: "idle",
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
config: { title: "Top-level agent" },
|
|
} as ManagedAgent);
|
|
const ensureWorkspace = vi.fn(async () => "workspace-created");
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
ensureWorkspaceForCreate: ensureWorkspace,
|
|
logger,
|
|
});
|
|
|
|
await registeredTool(server, "create_agent").handler({
|
|
title: "Top-level agent",
|
|
provider: "codex/gpt-5.4",
|
|
initialPrompt: "Do work",
|
|
});
|
|
|
|
expect(ensureWorkspace).toHaveBeenCalledWith(
|
|
existingCwd,
|
|
{ prompt: "Do work" },
|
|
{ callerWorkspaceId: undefined },
|
|
);
|
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cwd: existingCwd }),
|
|
undefined,
|
|
{ workspaceId: "workspace-created" },
|
|
);
|
|
});
|
|
|
|
it("rejects public internal helpers and the old top-level execution background option", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const agentManager = new AgentManager({
|
|
clients: createTestAgentClients(),
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
try {
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage: storage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_agent");
|
|
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
|
|
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
|
|
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
|
|
/Unrecognized key/,
|
|
);
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
|
|
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",
|
|
config: {},
|
|
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",
|
|
});
|
|
|
|
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",
|
|
|
|
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("returns create_agent structured content with 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",
|
|
});
|
|
|
|
expect(response.structuredContent).toEqual(
|
|
expect.objectContaining({
|
|
agentId: "mode-agent",
|
|
type: "codex",
|
|
status: "idle",
|
|
cwd: REPO_CWD,
|
|
currentModeId: "build",
|
|
availableModes: [
|
|
{
|
|
id: "build",
|
|
label: "Build",
|
|
description: null,
|
|
icon: "hammer",
|
|
colorTier: "dangerous",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
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("exposes workspace tools instead of worktree tools", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
expect(lookupTool(server, "create_workspace")).toBeDefined();
|
|
expect(lookupTool(server, "list_workspaces")).toBeDefined();
|
|
expect(lookupTool(server, "archive_workspace")).toBeDefined();
|
|
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
|
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
|
|
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
|
expect(lookupTool(server, "detach_agent")).toBeUndefined();
|
|
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
|
|
});
|
|
|
|
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("auto-titles an agent-created directory workspace from the initial prompt", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-"));
|
|
const workspaceDir = join(tempDir, "workspace");
|
|
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
|
|
const broadcasts: string[] = [];
|
|
const workspaceGitService = new WorkspaceGitServiceImpl({
|
|
logger: createTestLogger(),
|
|
paseoHome: join(tempDir, ".paseo"),
|
|
deps: { github: createGitHubServiceStub() },
|
|
});
|
|
const workspaceAutoName = new WorkspaceAutoName({
|
|
agentManager,
|
|
workspaceRegistry: {
|
|
update: async (workspaceId, updater) => {
|
|
const current = workspaceRecords.get(workspaceId);
|
|
if (!current) return null;
|
|
const updated = updater(current);
|
|
workspaceRecords.set(workspaceId, updated);
|
|
return updated;
|
|
},
|
|
},
|
|
workspaceGitService,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: createGitMutationService({
|
|
workspaceGitService,
|
|
github: createGitHubServiceStub(),
|
|
logger: createTestLogger(),
|
|
}),
|
|
emitWorkspaceUpdateForCwd: async () => {},
|
|
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
|
|
broadcasts.push(workspaceId);
|
|
},
|
|
logger: createTestLogger(),
|
|
generateWorkspaceName: async () => ({
|
|
title: "Directory Workspace Title",
|
|
branch: "directory-workspace-title",
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
|
id: "agent-directory-auto-title",
|
|
cwd: config.cwd,
|
|
lifecycle: "idle",
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
config: { title: "Directory agent" },
|
|
}));
|
|
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "workspace-directory-auto-title",
|
|
projectId: "project-directory-auto-title",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "workspace",
|
|
title: firstAgentContext?.prompt ?? null,
|
|
createdAt: "2026-07-03T00:00:00.000Z",
|
|
updatedAt: "2026-07-03T00:00:00.000Z",
|
|
});
|
|
workspaceRecords.set(workspace.workspaceId, workspace);
|
|
if (firstAgentContext) {
|
|
workspaceAutoName.scheduleForDirectory({
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
firstAgentContext,
|
|
});
|
|
}
|
|
return workspace.workspaceId;
|
|
},
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_agent");
|
|
await tool.handler({
|
|
...detachedDirectoryWorkspace(workspaceDir),
|
|
title: "Directory agent",
|
|
provider: "codex/gpt-5.4",
|
|
initialPrompt: "Name a directory workspace from the prompt",
|
|
});
|
|
await waitForWorkspaceTitle(
|
|
workspaceRecords,
|
|
"workspace-directory-auto-title",
|
|
"Directory Workspace Title",
|
|
);
|
|
|
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: workspaceDir,
|
|
title: "Directory agent",
|
|
}),
|
|
undefined,
|
|
{ workspaceId: "workspace-directory-auto-title" },
|
|
);
|
|
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
|
|
title: "Directory Workspace Title",
|
|
branch: null,
|
|
});
|
|
expect(broadcasts).toEqual(["workspace-directory-auto-title"]);
|
|
} finally {
|
|
await removeTempDir(tempDir);
|
|
}
|
|
});
|
|
|
|
it("creates a worktree-isolated workspace", 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[] = [];
|
|
|
|
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"
|
|
>,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_workspace");
|
|
const response = await tool.handler({
|
|
isolation: "worktree",
|
|
path: repoDir,
|
|
worktreeSlug: "tool-worktree",
|
|
branchName: "feature/tool-worktree",
|
|
baseBranch: "main",
|
|
});
|
|
|
|
expect(response.structuredContent.isolation).toBe("worktree");
|
|
expect(response.structuredContent.cwd).toContain("tool-worktree");
|
|
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(broadcasts).toHaveLength(1);
|
|
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
|
} finally {
|
|
await removeTempDir(tempDir);
|
|
}
|
|
});
|
|
|
|
it("creates a worktree workspace from a project root without a path", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "project-source",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "source",
|
|
createdAt: "2026-07-18T00:00:00.000Z",
|
|
updatedAt: "2026-07-18T00:00:00.000Z",
|
|
});
|
|
const receivedInputs: CreatePaseoWorktreeInput[] = [];
|
|
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
|
|
receivedInputs.push(input);
|
|
return {
|
|
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
|
|
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
|
|
workspace: createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-project-source",
|
|
projectId: project.projectId,
|
|
cwd: TARGET_CWD,
|
|
kind: "worktree",
|
|
displayName: "project-worktree",
|
|
title: input.title ?? null,
|
|
createdAt: "2026-07-18T00:00:00.000Z",
|
|
updatedAt: "2026-07-18T00:00:00.000Z",
|
|
}),
|
|
repoRoot: REPO_CWD,
|
|
created: true,
|
|
};
|
|
};
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
projectRegistry: {
|
|
get: async (projectId) => (projectId === project.projectId ? project : null),
|
|
list: async () => [project],
|
|
},
|
|
createPaseoWorktree,
|
|
logger,
|
|
});
|
|
|
|
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
|
|
isolation: "worktree",
|
|
projectId: project.projectId,
|
|
worktreeSlug: "project-worktree",
|
|
title: "Project workspace",
|
|
});
|
|
|
|
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
|
|
expect(receivedInputs).toEqual([
|
|
expect.objectContaining({
|
|
cwd: REPO_CWD,
|
|
projectId: project.projectId,
|
|
title: "Project workspace",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("preserves branch checkout and pull request checkout workspace modes", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
|
|
worktree: {
|
|
branchName: input.refName ?? "pr-42",
|
|
worktreePath: "/tmp/worktrees/selected",
|
|
},
|
|
intent: {
|
|
kind: "checkout-branch" as const,
|
|
branchName: input.refName ?? "pr-42",
|
|
},
|
|
workspace: createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-selected",
|
|
projectId: "project-1",
|
|
cwd: "/tmp/worktrees/selected",
|
|
kind: "worktree",
|
|
displayName: "selected",
|
|
createdAt: "2026-07-18T00:00:00.000Z",
|
|
updatedAt: "2026-07-18T00:00:00.000Z",
|
|
}),
|
|
repoRoot: REPO_CWD,
|
|
created: true,
|
|
}));
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
createPaseoWorktree,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_workspace");
|
|
|
|
await invokeToolWithParsedInput(tool, {
|
|
isolation: "worktree",
|
|
path: REPO_CWD,
|
|
mode: "checkout-branch",
|
|
branch: "existing-work",
|
|
worktreeSlug: "existing-work-copy",
|
|
});
|
|
await invokeToolWithParsedInput(tool, {
|
|
isolation: "worktree",
|
|
path: REPO_CWD,
|
|
mode: "checkout-pr",
|
|
prNumber: 42,
|
|
forge: "gitlab",
|
|
});
|
|
await invokeToolWithParsedInput(tool, {
|
|
isolation: "worktree",
|
|
path: REPO_CWD,
|
|
mode: "checkout-pr",
|
|
prNumber: 43,
|
|
});
|
|
|
|
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
action: "checkout",
|
|
refName: "existing-work",
|
|
worktreeSlug: "existing-work-copy",
|
|
}),
|
|
);
|
|
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
action: "checkout",
|
|
checkoutSource: {
|
|
kind: "change_request",
|
|
forge: "gitlab",
|
|
number: 42,
|
|
},
|
|
}),
|
|
);
|
|
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({
|
|
action: "checkout",
|
|
checkoutSource: {
|
|
kind: "change_request",
|
|
number: 43,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("archives a worktree-isolated workspace by workspace id", 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_workspace");
|
|
const archiveTool = registeredTool(server, "archive_workspace");
|
|
const created = await createTool.handler({
|
|
isolation: "worktree",
|
|
path: repoDir,
|
|
worktreeSlug: "archive-tool-worktree",
|
|
baseBranch: "main",
|
|
});
|
|
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
|
|
listActiveWorkspaces.mockImplementation(async () => [
|
|
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
|
|
]);
|
|
archiveWorkspaceRecord.mockImplementation(async () => {
|
|
listActiveWorkspaces.mockResolvedValueOnce([]);
|
|
});
|
|
workspaceGitService.getSnapshot.mockClear();
|
|
|
|
await archiveTool.handler({
|
|
workspaceId: "ws-archive-tool-worktree",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
|
force: true,
|
|
reason: "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 removeTempDir(tempDir);
|
|
}
|
|
});
|
|
|
|
it("rejects archiving a missing workspace", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
listActiveWorkspaces: async () => [],
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
|
|
).rejects.toThrow("Workspace not found: missing-workspace");
|
|
});
|
|
|
|
it("keeps an owned worktree while another workspace still references it", 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_workspace");
|
|
const archiveTool = registeredTool(server, "archive_workspace");
|
|
const created = await createTool.handler({
|
|
isolation: "worktree",
|
|
path: repoDir,
|
|
worktreeSlug: "archive-multi-worktree",
|
|
baseBranch: "main",
|
|
});
|
|
const worktreePath = z.string().parse(created.structuredContent.cwd);
|
|
|
|
// 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({
|
|
workspaceId: "ws-mcp-A",
|
|
});
|
|
|
|
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
|
|
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
|
|
await expect(access(worktreePath)).resolves.toBeUndefined();
|
|
} finally {
|
|
await removeTempDir(tempDir);
|
|
}
|
|
});
|
|
|
|
it("does not expose worktree path or slug operations", 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,
|
|
});
|
|
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
|
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
|
} finally {
|
|
await removeTempDir(tempDir);
|
|
}
|
|
});
|
|
|
|
it("lists active workspace descriptors", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-feature",
|
|
projectId: "project-1",
|
|
cwd: "/tmp/paseo/worktrees/repo/feature",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-07-17T00:00:00.000Z",
|
|
updatedAt: "2026-07-17T00:00:00.000Z",
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
workspaceRegistry: {
|
|
get: vi.fn(async () => workspace),
|
|
list: vi.fn(async () => [workspace]),
|
|
upsert: vi.fn(async () => undefined),
|
|
},
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "list_workspaces");
|
|
|
|
const response = await tool.handler({});
|
|
|
|
expect(response.structuredContent.workspaces).toEqual([
|
|
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
|
|
]);
|
|
});
|
|
|
|
it("creates an background local workspace and leaves it out of list_workspaces", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createCalls: Array<{ cwd: string; context: { background?: boolean } | undefined }> = [];
|
|
const backgroundWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-background",
|
|
projectId: "project-1",
|
|
cwd: process.cwd(),
|
|
kind: "directory",
|
|
displayName: "helper",
|
|
createdAt: "2026-07-17T00:00:00.000Z",
|
|
updatedAt: "2026-07-17T00:00:00.000Z",
|
|
background: true,
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
workspaceRegistry: {
|
|
get: vi.fn(async () => backgroundWorkspace),
|
|
list: vi.fn(async () => [backgroundWorkspace]),
|
|
upsert: vi.fn(async () => undefined),
|
|
},
|
|
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
|
|
createCalls.push({ cwd, context });
|
|
return backgroundWorkspace;
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const created = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
|
|
isolation: "local",
|
|
path: process.cwd(),
|
|
background: true,
|
|
});
|
|
expect(created.structuredContent.workspaceId).toBe("ws-background");
|
|
expect(createCalls).toEqual([{ cwd: process.cwd(), context: { background: true } }]);
|
|
|
|
const listed = await registeredTool(server, "list_workspaces").handler({});
|
|
expect(listed.structuredContent.workspaces).toEqual([]);
|
|
});
|
|
|
|
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",
|
|
config: { title: "Voice" },
|
|
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",
|
|
config: {},
|
|
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",
|
|
config: {},
|
|
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 poll for status; continue with other work until the notification arrives.",
|
|
);
|
|
});
|
|
|
|
it("notifies the caller when a created child finishes its initial turn before the turn start returns", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-create-fast-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true });
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
|
|
const response = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
|
...subagentCurrentWorkspace(),
|
|
title: "Fast Child",
|
|
provider: "codex/gpt-5.4",
|
|
initialPrompt: "Finish immediately",
|
|
});
|
|
const childId = z.object({ agentId: z.string() }).parse(response.structuredContent).agentId;
|
|
|
|
await vi.waitFor(() => {
|
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
|
expect(parentPrompts).toHaveLength(1);
|
|
expect(parentPrompts[0]).toContain(childId);
|
|
expect(parentPrompts[0]).toContain("finished");
|
|
});
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
|
|
it("creates detached caller agents without a parent label", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
spies.agentManager.getAgent.mockReturnValue({
|
|
id: "parent-agent",
|
|
config: {},
|
|
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",
|
|
config: {},
|
|
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 provider options only when the child uses the caller provider", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const parentAgent = {
|
|
id: "parent-agent",
|
|
cwd: existingCwd,
|
|
workspaceId: "wks_parent",
|
|
provider: "codex",
|
|
currentModeId: null,
|
|
config: {
|
|
providerOptions: {
|
|
sandbox_mode: "workspace-write",
|
|
sandbox_workspace_write: { writable_roots: ["/tmp/shared"] },
|
|
},
|
|
},
|
|
} as ManagedAgent;
|
|
spies.agentManager.getAgent.mockReturnValue(parentAgent);
|
|
spies.agentManager.createAgent.mockResolvedValue({
|
|
id: "child-agent",
|
|
cwd: existingCwd,
|
|
lifecycle: "idle",
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
config: { title: "Child" },
|
|
} as ManagedAgent);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
|
|
await registeredTool(server, "create_agent").handler({
|
|
...subagentCurrentWorkspace(),
|
|
title: "Codex child",
|
|
provider: "codex/gpt-5.4",
|
|
initialPrompt: "Do work",
|
|
});
|
|
expect(spies.agentManager.createAgent).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ providerOptions: parentAgent.config.providerOptions }),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
|
|
await registeredTool(server, "create_agent").handler({
|
|
...subagentCurrentWorkspace(),
|
|
title: "Claude child",
|
|
provider: "claude/sonnet",
|
|
initialPrompt: "Do work",
|
|
settings: { modeId: "default" },
|
|
});
|
|
expect(spies.agentManager.createAgent).toHaveBeenLastCalledWith(
|
|
expect.not.objectContaining({ providerOptions: expect.anything() }),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
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 {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
|
|
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",
|
|
config: {},
|
|
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",
|
|
config: {},
|
|
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),
|
|
);
|
|
});
|
|
});
|
|
|
|
const HELD_TURN_CAPABILITIES = {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: false,
|
|
supportsSessionListing: false,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
} as const;
|
|
|
|
interface HeldTurnOptions {
|
|
finishDuringSteer?: boolean;
|
|
rejectSteer?: boolean;
|
|
steerResult?: "accepted" | "unavailable";
|
|
/** Run the whole turn before the turn-start request returns. */
|
|
finishTurnsDuringStart?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Provider session that records every prompt it receives. With `holdTurns`, a started
|
|
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
|
|
*/
|
|
class HeldTurnAgentSession implements AgentSession {
|
|
readonly capabilities = HELD_TURN_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
readonly prompts: string[] = [];
|
|
readonly steeredPrompts: AgentPromptInput[] = [];
|
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
|
private readonly permissions = new Map<string, AgentPermissionRequest>();
|
|
private activeTurnId: string | null = null;
|
|
private turnStartGate: Promise<void> | null = null;
|
|
private nextStartError: Error | null = null;
|
|
|
|
constructor(
|
|
readonly provider: AgentProvider,
|
|
private readonly holdTurns: boolean,
|
|
private readonly options: HeldTurnOptions = {},
|
|
) {}
|
|
|
|
/** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */
|
|
holdTurnStart(): () => void {
|
|
let release!: () => void;
|
|
this.turnStartGate = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return release;
|
|
}
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
|
}
|
|
|
|
rejectNextTurnStart(error: Error): void {
|
|
this.nextStartError = error;
|
|
}
|
|
|
|
tryHandleOutOfBand(prompt: AgentPromptInput) {
|
|
return prompt === "/side-command" ? { run: async () => {} } : null;
|
|
}
|
|
|
|
async steerActiveTurn(prompt: AgentPromptInput) {
|
|
const status = this.options.steerResult ?? "unavailable";
|
|
if (status === "accepted") this.steeredPrompts.push(prompt);
|
|
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
|
|
if (this.options.rejectSteer) throw new Error("Steer rejected");
|
|
return { status };
|
|
}
|
|
|
|
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
|
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
|
await this.turnStartGate;
|
|
if (this.nextStartError) {
|
|
const error = this.nextStartError;
|
|
this.nextStartError = null;
|
|
throw error;
|
|
}
|
|
const turnId = randomUUID();
|
|
if (this.options.finishTurnsDuringStart) {
|
|
// The whole turn runs before the turn-start request returns, as a provider whose
|
|
// start acknowledgment arrives together with the turn's completion does.
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
return { turnId };
|
|
}
|
|
this.activeTurnId = turnId;
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: {
|
|
type: "user_message",
|
|
text: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
|
|
messageId: randomUUID(),
|
|
},
|
|
});
|
|
setTimeout(() => {
|
|
if (this.activeTurnId !== turnId) return;
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
if (!this.holdTurns && this.activeTurnId === turnId) {
|
|
this.finishTurn();
|
|
}
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
finishTurn(finalText?: string): void {
|
|
const turnId = this.activeTurnId;
|
|
if (!turnId) {
|
|
throw new Error("No held turn to finish");
|
|
}
|
|
this.activeTurnId = null;
|
|
if (finalText)
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: { type: "assistant_message", text: finalText },
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
|
|
failTurn(error: string): void {
|
|
const turnId = this.activeTurnId;
|
|
if (!turnId) throw new Error("No held turn to fail");
|
|
this.activeTurnId = null;
|
|
this.pushEvent({ type: "turn_failed", provider: this.provider, error, turnId });
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subscribers.add(callback);
|
|
return () => {
|
|
this.subscribers.delete(callback);
|
|
};
|
|
}
|
|
|
|
private pushEvent(event: AgentStreamEvent): void {
|
|
for (const callback of this.subscribers) {
|
|
callback(event);
|
|
}
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
requestPermission(id: string): void {
|
|
const request: AgentPermissionRequest = {
|
|
id,
|
|
name: "Write",
|
|
kind: "tool",
|
|
input: { path: id },
|
|
};
|
|
this.permissions.set(id, request);
|
|
this.pushEvent({ type: "permission_requested", provider: this.provider, request });
|
|
}
|
|
|
|
getPendingPermissions() {
|
|
return [...this.permissions.values()];
|
|
}
|
|
|
|
async respondToPermission(requestId: string): Promise<void> {
|
|
this.permissions.delete(requestId);
|
|
this.pushEvent({ type: "permission_resolved", provider: this.provider, requestId });
|
|
}
|
|
|
|
describePersistence() {
|
|
return { provider: this.provider, sessionId: this.id };
|
|
}
|
|
|
|
async interrupt(): Promise<void> {
|
|
const turnId = this.activeTurnId;
|
|
if (!turnId) {
|
|
return;
|
|
}
|
|
this.activeTurnId = null;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "interrupted",
|
|
turnId,
|
|
});
|
|
}
|
|
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class HeldTurnAgentClient implements AgentClient {
|
|
readonly capabilities = HELD_TURN_CAPABILITIES;
|
|
readonly sessions: HeldTurnAgentSession[] = [];
|
|
|
|
constructor(
|
|
readonly provider: AgentProvider,
|
|
private readonly holdTurns: boolean,
|
|
private readonly options: HeldTurnOptions = {},
|
|
) {}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options);
|
|
this.sessions.push(session);
|
|
return session;
|
|
}
|
|
|
|
async fetchCatalog() {
|
|
return { models: [], modes: [] };
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
return await this.createSession();
|
|
}
|
|
}
|
|
|
|
describe("send_agent_prompt MCP tool", () => {
|
|
it.each([
|
|
{ agentScoped: true, behavior: undefined, available: true, steered: true },
|
|
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
|
|
{ agentScoped: true, behavior: "steer", available: true, steered: true },
|
|
{ agentScoped: false, behavior: undefined, available: true, steered: false },
|
|
{ agentScoped: false, behavior: "steer", available: true, steered: true },
|
|
{ agentScoped: true, behavior: undefined, available: false, steered: false },
|
|
] as const)(
|
|
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
|
|
async ({ agentScoped, behavior, available, steered }) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldTurnAgentClient("codex", true, {
|
|
steerResult: available ? "accepted" : "unavailable",
|
|
});
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: agentScoped ? parent.id : undefined,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "send_agent_prompt");
|
|
await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Original work",
|
|
notifyOnFinish: false,
|
|
});
|
|
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "New message",
|
|
notifyOnFinish: false,
|
|
...(behavior ? { activeTurnBehavior: behavior } : {}),
|
|
});
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
...(agentScoped
|
|
? { deliveryStatus: "queued" }
|
|
: { disposition: steered ? "steered" : "turn_started" }),
|
|
});
|
|
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
|
|
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
|
|
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
|
|
} finally {
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ rejected: false, priorNotification: false },
|
|
{ rejected: true, priorNotification: false },
|
|
{ rejected: false, priorNotification: true },
|
|
])(
|
|
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
|
|
async ({ rejected, priorNotification }) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldTurnAgentClient("codex", true, {
|
|
steerResult: "accepted",
|
|
finishDuringSteer: true,
|
|
rejectSteer: rejected,
|
|
});
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
let mcpClient: Client | undefined;
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
mcpClient = await connectInMemoryMcpClient(server);
|
|
const send = (input: Record<string, unknown>) =>
|
|
sendMcpAndAwaitDispatch(mcpClient!, server, input);
|
|
await send({
|
|
agentId: child.id,
|
|
prompt: "Original work",
|
|
notifyOnFinish: priorNotification,
|
|
});
|
|
const sending = send({
|
|
agentId: child.id,
|
|
prompt: "New message",
|
|
notifyOnFinish: true,
|
|
});
|
|
if (rejected) {
|
|
await expect(sending).rejects.toThrow("Steer rejected");
|
|
// Flush asynchronous notification work before checking that a rejected
|
|
// dispatch did not notify. Then prove a later successful send still can.
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
|
await send({
|
|
agentId: child.id,
|
|
prompt: "Retry after rejection",
|
|
});
|
|
client.sessions[1]!.finishTurn();
|
|
} else {
|
|
const response = await sending;
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
});
|
|
}
|
|
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
|
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
|
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
|
});
|
|
} finally {
|
|
await mcpClient?.close();
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"preserves the original notification after rejection (completion during send: %s)",
|
|
async (finishDuringSteer) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldTurnAgentClient("codex", true, {
|
|
steerResult: "accepted",
|
|
finishDuringSteer,
|
|
rejectSteer: true,
|
|
});
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
let mcpClient: Client | undefined;
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{
|
|
workspaceId: "wks_parent",
|
|
},
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{
|
|
workspaceId: "wks_parent",
|
|
},
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
mcpClient = await connectInMemoryMcpClient(server);
|
|
const send = (input: Record<string, unknown>) =>
|
|
sendMcpAndAwaitDispatch(mcpClient!, server, input);
|
|
await send({
|
|
agentId: child.id,
|
|
prompt: "Original work",
|
|
notifyOnFinish: true,
|
|
});
|
|
const sending = send({
|
|
agentId: child.id,
|
|
prompt: "New message",
|
|
notifyOnFinish: true,
|
|
});
|
|
await expect(sending).rejects.toThrow("Steer rejected");
|
|
if (!finishDuringSteer) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
|
client.sessions[1]!.finishTurn();
|
|
}
|
|
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
|
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
|
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
|
});
|
|
} finally {
|
|
await mcpClient?.close();
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[false, true],
|
|
[true, false],
|
|
[false, false],
|
|
[true, true],
|
|
])(
|
|
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
|
|
async (firstAccepted, secondAccepted) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldTurnAgentClient("codex", true, {
|
|
steerResult: "accepted",
|
|
finishDuringSteer: false,
|
|
rejectSteer: true,
|
|
});
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
let mcpClient: Client | undefined;
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{
|
|
workspaceId: "wks_parent",
|
|
},
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{
|
|
workspaceId: "wks_parent",
|
|
},
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
mcpClient = await connectInMemoryMcpClient(server);
|
|
const send = (input: Record<string, unknown>) =>
|
|
sendMcpAndAwaitDispatch(mcpClient!, server, input);
|
|
await send({
|
|
agentId: child.id,
|
|
prompt: "Original work",
|
|
notifyOnFinish: true,
|
|
});
|
|
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
|
|
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
|
|
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
|
firstResolve = resolve;
|
|
});
|
|
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
|
secondResolve = resolve;
|
|
});
|
|
for (const dispatchResult of [first, second]) {
|
|
const registration = await serverSubscriptions
|
|
.get(server)!
|
|
.register({ childAgentId: child.id, callerAgentId: parent.id }, true);
|
|
void dispatchResult.then((result) => {
|
|
if (result) registration.accept();
|
|
else void registration.reject();
|
|
return undefined;
|
|
});
|
|
}
|
|
firstResolve(firstAccepted ? "steered" : false);
|
|
await first;
|
|
secondResolve(secondAccepted ? "steered" : false);
|
|
await second;
|
|
client.sessions[1]!.finishTurn();
|
|
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(client.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
|
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
|
});
|
|
} finally {
|
|
await mcpClient?.close();
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
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",
|
|
config: {},
|
|
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({
|
|
notifyOnFinish: true,
|
|
});
|
|
|
|
const response = await tool.handler(parsed.data as Record<string, unknown>);
|
|
|
|
expect(response.structuredContent).toMatchObject({ deliveryStatus: "queued" });
|
|
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
|
|
await serverDeliveries
|
|
.get(server)!
|
|
.waitForDispatch("child-agent", new AbortController().signal);
|
|
expect(serverSubscriptionRecords.get(server)!.size).toBe(1);
|
|
expect(response.structuredContent.guidance).toContain("You will get notified");
|
|
});
|
|
|
|
it("acknowledges top-level prompts without a completion wait", 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({
|
|
notifyOnFinish: false,
|
|
});
|
|
|
|
await tool.handler(parsed.data as Record<string, unknown>);
|
|
|
|
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
|
|
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows agent-scoped callers to opt out of finish notifications", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const parentAgent = {
|
|
id: "parent-agent",
|
|
config: {},
|
|
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",
|
|
notifyOnFinish: false,
|
|
});
|
|
|
|
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
|
|
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ immediateFinish: false, failure: false },
|
|
{ immediateFinish: true, failure: false },
|
|
{ immediateFinish: false, failure: true },
|
|
])(
|
|
"notifies the retry outcome rather than the prior failed turn (immediate: $immediateFinish, failure: $failure)",
|
|
async ({ immediateFinish, failure }) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-retry-notification-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = await agentManager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd, title: "Retry child" },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "send_agent_prompt");
|
|
await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Old task",
|
|
notifyOnFinish: false,
|
|
});
|
|
childClient.sessions[0]!.failTurn("Old task failed");
|
|
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("error"));
|
|
const unsubscribe = agentManager.subscribe(
|
|
(event) => {
|
|
if (
|
|
immediateFinish &&
|
|
event.type === "agent_state" &&
|
|
event.agent.lifecycle === "running"
|
|
) {
|
|
childClient.sessions[0]!.finishTurn("Retry done");
|
|
}
|
|
},
|
|
{ agentId: child.id, replayState: false },
|
|
);
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Retry now",
|
|
});
|
|
unsubscribe();
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
});
|
|
if (!immediateFinish) {
|
|
expect(response.structuredContent.deliveryStatus).toBe("queued");
|
|
if (failure) childClient.sessions[0]!.failTurn("Retry failed");
|
|
else childClient.sessions[0]!.finishTurn("Retry done");
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parentClient.sessions[0]!.prompts[0]).toContain(
|
|
`Agent ${child.id} (Retry child) ${failure ? "errored" : "finished"}.`,
|
|
);
|
|
expect(parentClient.sessions[0]!.prompts[0]).not.toContain("Old task failed");
|
|
});
|
|
await vi.waitFor(() => expect(agentManager.getAgent(parent.id)?.lifecycle).toBe("idle"));
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
|
|
"preserves %s dispatch notification semantics",
|
|
async (scenario) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = 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, "send_agent_prompt");
|
|
await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Ongoing task",
|
|
notifyOnFinish: false,
|
|
});
|
|
if (scenario !== "startup_failure") {
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "/side-command",
|
|
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
|
|
});
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
});
|
|
expect(childClient.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({
|
|
source: { kind: "agent-message", agentId: parent.id },
|
|
text: "Ongoing task",
|
|
});
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
|
|
childClient.sessions[0]!.finishTurn("Ongoing task done");
|
|
await vi.waitFor(() => {
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parentClient.sessions[0]!.prompts[0]).toContain("finished.");
|
|
});
|
|
} else {
|
|
childClient.sessions[0]!.rejectNextTurnStart(new Error("Requested turn could not start"));
|
|
await expect(
|
|
invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Replacement task" }),
|
|
).rejects.toThrow("Requested turn could not start");
|
|
// A later real turn must not inherit a subscription from the rejected dispatch.
|
|
await invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Recovery",
|
|
notifyOnFinish: false,
|
|
});
|
|
childClient.sessions[0]!.finishTurn("Recovered");
|
|
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle"));
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
|
|
}
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("acknowledges a pending agent-scoped turn and notifies its completion", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = 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, "send_agent_prompt");
|
|
|
|
const pending = invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Follow up",
|
|
});
|
|
const response = await pending;
|
|
expect(response.structuredContent).toMatchObject({
|
|
deliveryStatus: "queued",
|
|
guidance: expect.stringContaining("You will get notified"),
|
|
});
|
|
expect(response.structuredContent).not.toHaveProperty("lastMessage");
|
|
|
|
childClient.sessions[0]!.finishTurn();
|
|
|
|
await vi.waitFor(() => {
|
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
|
expect(parentPrompts).toHaveLength(1);
|
|
expect(parentPrompts[0]).toContain(child.id);
|
|
expect(parentPrompts[0]).toContain("finished");
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
it("notifies the caller once when it prompts a created child that is still running", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-running-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
|
|
const created = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
|
relationship: { kind: "subagent" },
|
|
workspace: { kind: "current" },
|
|
title: "Busy Child",
|
|
provider: "codex/gpt-5.4",
|
|
initialPrompt: "Run a long command",
|
|
});
|
|
const childId = z.object({ agentId: z.string() }).parse(created.structuredContent).agentId;
|
|
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
|
|
|
|
const sent = await invokeToolWithParsedInput(registeredTool(server, "send_agent_prompt"), {
|
|
agentId: childId,
|
|
prompt: "Stop and reply instead",
|
|
});
|
|
expect(sent.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
});
|
|
const childSession = childClient.sessions[0]!;
|
|
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2));
|
|
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
|
|
childSession.finishTurn();
|
|
|
|
function finishNotifications() {
|
|
return (parentClient.sessions[0]?.prompts ?? []).filter((prompt) =>
|
|
prompt.includes(`Agent ${childId} (Busy Child) finished.`),
|
|
);
|
|
}
|
|
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(finishNotifications()).toHaveLength(1);
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({
|
|
source: { kind: "agent-notification", agentId: childId, event: "finished" },
|
|
});
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
|
|
it("reports queue admission before the background prompt starts", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = 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, "send_agent_prompt");
|
|
const childSession = childClient.sessions[0]!;
|
|
const acknowledgeTurnStart = childSession.holdTurnStart();
|
|
|
|
const pending = invokeToolWithParsedInput(tool, {
|
|
agentId: child.id,
|
|
prompt: "Follow up",
|
|
});
|
|
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1));
|
|
expect(parseAgentMessage(childSession.prompts[0])).toEqual({
|
|
id: expect.any(String),
|
|
source: { kind: "agent-message", agentId: parent.id },
|
|
text: "Follow up",
|
|
});
|
|
acknowledgeTurnStart();
|
|
const response = await pending;
|
|
|
|
expect(response.structuredContent).toMatchObject({ success: true, deliveryStatus: "queued" });
|
|
expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running");
|
|
|
|
childSession.finishTurn();
|
|
await vi.waitFor(() => {
|
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
|
expect(parentPrompts).toHaveLength(1);
|
|
expect(parentPrompts[0]).toContain(child.id);
|
|
expect(parentPrompts[0]).toContain("finished");
|
|
});
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
|
|
it("accepts a background turn completed as the manager publishes its start", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-fast-background-send-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const agentManager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = 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,
|
|
});
|
|
let finished = false;
|
|
const unsubscribe = agentManager.subscribe(
|
|
(event) => {
|
|
if (
|
|
!finished &&
|
|
event.type === "agent_state" &&
|
|
event.agent.id === child.id &&
|
|
event.agent.lifecycle === "running"
|
|
) {
|
|
finished = true;
|
|
childClient.sessions[0]!.finishTurn();
|
|
}
|
|
},
|
|
{ agentId: child.id, replayState: false },
|
|
);
|
|
|
|
const response = await invokeToolWithParsedInput(
|
|
registeredTool(server, "send_agent_prompt"),
|
|
{
|
|
agentId: child.id,
|
|
prompt: "Fast reply",
|
|
},
|
|
);
|
|
unsubscribe();
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
});
|
|
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle"));
|
|
await vi.waitFor(() => {
|
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
|
expect(parentPrompts).toHaveLength(1);
|
|
expect(parentPrompts[0]).toContain(child.id);
|
|
expect(parentPrompts[0]).toContain("finished");
|
|
});
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("send_agent_prompt MCP tool to an OMP child", () => {
|
|
const logger = createTestLogger();
|
|
const existingCwd = process.cwd();
|
|
|
|
async function setupOmpChild() {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-omp-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const omp = new FakeOmp();
|
|
const agentManager = new AgentManager({
|
|
clients: {
|
|
claude: parentClient,
|
|
omp: new OmpAgentClient({
|
|
logger,
|
|
runtime: omp,
|
|
noTurnScheduler: { waitForSettle: async () => {} },
|
|
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
|
|
}),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await agentManager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = await agentManager.createAgent(
|
|
{ provider: "omp", cwd: existingCwd, title: "OMP child" },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
return {
|
|
agentManager,
|
|
storage,
|
|
workdir,
|
|
parentClient,
|
|
ompSession: omp.latestSession(),
|
|
childId: child.id,
|
|
tool: registeredTool(server, "send_agent_prompt"),
|
|
};
|
|
}
|
|
|
|
async function expectOneFinishNotification(
|
|
agentManager: AgentManager,
|
|
parentClient: HeldTurnAgentClient,
|
|
childId: string,
|
|
) {
|
|
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("idle"));
|
|
await vi.waitFor(() => {
|
|
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
|
expect(parentClient.sessions[0]!.prompts[0]).toContain(
|
|
`Agent ${childId} (OMP child) finished.`,
|
|
);
|
|
});
|
|
}
|
|
|
|
it.each([
|
|
{ command: "follow-up", streamingBehavior: "followUp" },
|
|
{ command: "steer", streamingBehavior: "steer" },
|
|
] as const)(
|
|
"delivers /$command into a turn OMP started on its own, without aborting it",
|
|
async ({ command, streamingBehavior }) => {
|
|
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
|
|
await setupOmpChild();
|
|
try {
|
|
ompSession.beginTurn();
|
|
ompSession.streamAssistantText("background job finished, working on it");
|
|
await vi.waitFor(() => {
|
|
expect(agentManager.getAgent(childId)?.activeTurnId).toMatch(/^autonomous-/);
|
|
});
|
|
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
agentId: childId,
|
|
prompt: `/${command} then summarize the logs`,
|
|
});
|
|
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
status: "running",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(ompSession.prompts).toEqual([
|
|
{ message: "then summarize the logs", imageCount: 0, streamingBehavior },
|
|
]);
|
|
});
|
|
expect(ompSession.abortRequested).toBe(false);
|
|
expect(agentManager.getAgent(childId)?.lifecycle).toBe("running");
|
|
|
|
ompSession.finishTurn();
|
|
await expectOneFinishNotification(agentManager, parentClient, childId);
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["before ack", "after ack"])(
|
|
"reports an idle follow-up rejected %s and notifies its parent",
|
|
async (order) => {
|
|
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
|
|
await setupOmpChild();
|
|
try {
|
|
ompSession.promptAck = { requestId: "follow-up-rejected" };
|
|
const rejection = {
|
|
type: "prompt_result",
|
|
id: "follow-up-rejected",
|
|
agentInvoked: false,
|
|
status: "error",
|
|
sessionSettled: true,
|
|
error: { message: "Provider rejected the follow-up", retryable: false },
|
|
} as const;
|
|
const promptStarted = ompSession.nextPrompt();
|
|
const response = invokeToolWithParsedInput(tool, {
|
|
agentId: childId,
|
|
prompt: "/follow-up check the build",
|
|
});
|
|
await promptStarted;
|
|
if (order === "before ack") ompSession.emit(rejection);
|
|
await response;
|
|
if (order === "after ack") ompSession.emit(rejection);
|
|
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("error"));
|
|
expect(agentManager.getAgent(childId)?.lastError).toContain(
|
|
"Provider rejected the follow-up",
|
|
);
|
|
await vi.waitFor(() => expect(parentClient.sessions[0]!.prompts).toHaveLength(1));
|
|
expect(parentClient.sessions[0]!.prompts[0]).toContain(
|
|
`Agent ${childId} (OMP child) errored.`,
|
|
);
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("lets an idle child start a run with the follow-up", async () => {
|
|
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
|
|
await setupOmpChild();
|
|
try {
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
agentId: childId,
|
|
prompt: "/follow-up check the build",
|
|
});
|
|
|
|
expect(response.structuredContent).toMatchObject({
|
|
success: true,
|
|
deliveryStatus: "queued",
|
|
status: "idle",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(ompSession.prompts).toEqual([
|
|
{ message: "check the build", imageCount: 0, streamingBehavior: "followUp" },
|
|
]);
|
|
});
|
|
|
|
ompSession.beginTurn();
|
|
ompSession.streamAssistantText("Build is green");
|
|
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
|
|
ompSession.finishTurn();
|
|
await expectOneFinishNotification(agentManager, parentClient, childId);
|
|
} finally {
|
|
await removeAgentStateDir(agentManager, storage, workdir);
|
|
}
|
|
});
|
|
});
|
|
|
|
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("rename_workspace MCP tool", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it("renames the caller workspace when workspaceId is omitted", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "wks_parent",
|
|
projectId: "proj_parent",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-07-03T09:00:00.000Z",
|
|
updatedAt: "2026-07-03T09:00:00.000Z",
|
|
});
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
|
|
const emittedWorkspaceIds: string[][] = [];
|
|
spies.agentManager.getAgent.mockReturnValue(
|
|
createManagedAgent({
|
|
id: "parent-agent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspace.workspaceId,
|
|
}),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
workspaceRegistry: {
|
|
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (record) => {
|
|
upsertedWorkspaces.push(record);
|
|
workspaces.set(record.workspaceId, record);
|
|
},
|
|
},
|
|
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
|
|
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
|
},
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "rename_workspace");
|
|
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
title: " Payments flow ",
|
|
});
|
|
|
|
expect(upsertedWorkspaces).toEqual([
|
|
{
|
|
...workspace,
|
|
title: "Payments flow",
|
|
updatedAt: expect.any(String),
|
|
},
|
|
]);
|
|
expect(response.structuredContent).toEqual({
|
|
success: true,
|
|
workspaceId: "wks_parent",
|
|
title: "Payments flow",
|
|
});
|
|
expect(emittedWorkspaceIds).toEqual([["wks_parent"]]);
|
|
});
|
|
|
|
it("renames an explicit workspace outside the caller workspace", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const parentWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "wks_parent",
|
|
projectId: "proj_parent",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-07-03T09:00:00.000Z",
|
|
updatedAt: "2026-07-03T09:00:00.000Z",
|
|
});
|
|
const otherWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "wks_other",
|
|
projectId: "proj_other",
|
|
cwd: TARGET_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "other",
|
|
createdAt: "2026-07-03T09:00:00.000Z",
|
|
updatedAt: "2026-07-03T09:00:00.000Z",
|
|
});
|
|
const workspaces = new Map([
|
|
[parentWorkspace.workspaceId, parentWorkspace],
|
|
[otherWorkspace.workspaceId, otherWorkspace],
|
|
]);
|
|
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
|
|
const emittedWorkspaceIds: string[][] = [];
|
|
spies.agentManager.getAgent.mockReturnValue(
|
|
createManagedAgent({
|
|
id: "parent-agent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: parentWorkspace.workspaceId,
|
|
}),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
workspaceRegistry: {
|
|
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (record) => {
|
|
upsertedWorkspaces.push(record);
|
|
workspaces.set(record.workspaceId, record);
|
|
},
|
|
},
|
|
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
|
|
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
|
},
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "rename_workspace");
|
|
|
|
const response = await invokeToolWithParsedInput(tool, {
|
|
workspaceId: "wks_other",
|
|
title: "Payments flow",
|
|
});
|
|
|
|
expect(upsertedWorkspaces).toEqual([
|
|
{
|
|
...otherWorkspace,
|
|
title: "Payments flow",
|
|
updatedAt: expect.any(String),
|
|
},
|
|
]);
|
|
expect(response.structuredContent).toEqual({
|
|
success: true,
|
|
workspaceId: "wks_other",
|
|
title: "Payments flow",
|
|
});
|
|
expect(emittedWorkspaceIds).toEqual([["wks_other"]]);
|
|
});
|
|
|
|
it("rejects archived workspaces", async () => {
|
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "wks_archived",
|
|
projectId: "proj_parent",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
archivedAt: "2026-07-03T10:00:00.000Z",
|
|
createdAt: "2026-07-03T09:00:00.000Z",
|
|
updatedAt: "2026-07-03T10:00:00.000Z",
|
|
});
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
|
|
const emittedWorkspaceIds: string[][] = [];
|
|
spies.agentManager.getAgent.mockReturnValue(
|
|
createManagedAgent({
|
|
id: "parent-agent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspace.workspaceId,
|
|
}),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
workspaceRegistry: {
|
|
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (record) => {
|
|
upsertedWorkspaces.push(record);
|
|
workspaces.set(record.workspaceId, record);
|
|
},
|
|
},
|
|
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
|
|
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
|
},
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "rename_workspace");
|
|
|
|
await expect(
|
|
invokeToolWithParsedInput(tool, {
|
|
title: "Payments flow",
|
|
}),
|
|
).rejects.toThrow("Workspace wks_archived is archived");
|
|
expect(upsertedWorkspaces).toEqual([]);
|
|
expect(emittedWorkspaceIds).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("create_schedule MCP tool", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it("requires provider for schedules", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
|
createStoredSchedule(input),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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");
|
|
expect(createOrReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
|
createStoredSchedule(input),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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",
|
|
});
|
|
await tool.handler({
|
|
prompt: "say hello in a worktree",
|
|
cron: "*/15 * * * *",
|
|
provider: "codex",
|
|
isolation: "worktree",
|
|
});
|
|
|
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
isolation: "worktree",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("inherits the caller provider, model, and features when provider is omitted", 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 createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
|
createStoredSchedule(input),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_schedule");
|
|
|
|
const response = await tool.handler({
|
|
prompt: "say hello",
|
|
cron: "*/5 * * * *",
|
|
});
|
|
|
|
expect(response.structuredContent.target).toEqual({
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "opencode",
|
|
cwd: REPO_CWD,
|
|
modeId: "build",
|
|
model: "openai/gpt-5.5",
|
|
featureValues: { auto_accept: true },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("passes timezone through cron create_schedule input", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createOrReplace = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
|
createStoredSchedule(scheduleInput),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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(createOrReplace).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 createOrReplace = vi.fn();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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(createOrReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects create_schedule without cron", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createOrReplace = vi.fn();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "create_schedule");
|
|
|
|
await expect(
|
|
tool.handler({
|
|
prompt: "say hello",
|
|
provider: "codex",
|
|
}),
|
|
).rejects.toThrow(/cron/);
|
|
|
|
expect(createOrReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const createOrReplace = vi.fn();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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(createOrReplace).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 createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
|
createStoredSchedule(input),
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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(createOrReplace).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 createOrReplace = vi.fn();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { createOrReplace } 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(createOrReplace).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("heartbeat ownership MCP tools", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it("deletes the caller's heartbeat", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const heartbeat = createStoredSchedule({
|
|
prompt: "check status",
|
|
cadence: { type: "cron", expression: "*/15 * * * *" },
|
|
target: { type: "agent", agentId: "parent-agent" },
|
|
});
|
|
const inspect = vi.fn(async () => heartbeat);
|
|
const deleteSchedule = vi.fn(async () => undefined);
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: {
|
|
inspect,
|
|
delete: deleteSchedule,
|
|
} as unknown as ScheduleService,
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
|
|
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
|
|
|
|
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
|
|
});
|
|
|
|
it("rejects another agent's heartbeat", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const foreignHeartbeat = createStoredSchedule({
|
|
prompt: "foreign",
|
|
cadence: { type: "cron", expression: "0 * * * *" },
|
|
target: { type: "agent", agentId: "other-agent" },
|
|
});
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: {
|
|
inspect: vi.fn(async () => foreignHeartbeat),
|
|
delete: vi.fn(),
|
|
} as unknown as ScheduleService,
|
|
callerAgentId: "parent-agent",
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
|
|
).rejects.toThrow("does not belong to caller");
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
function scheduleServiceWithUpdate(
|
|
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
|
|
stored = makeStoredSchedule(),
|
|
): ScheduleService {
|
|
return {
|
|
update,
|
|
inspect: vi.fn(async () => stored),
|
|
} as unknown as ScheduleService;
|
|
}
|
|
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "update_schedule");
|
|
|
|
await tool.handler({
|
|
id: "schedule-1",
|
|
every: "10m",
|
|
});
|
|
|
|
expect(update).toHaveBeenCalledWith({
|
|
id: "schedule-1",
|
|
cadence: { type: "cron", expression: "*/10 * * * *" },
|
|
});
|
|
});
|
|
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "update_schedule");
|
|
|
|
await invokeToolWithParsedInput(tool, {
|
|
id: "schedule-1",
|
|
every: "10m",
|
|
cron: "",
|
|
});
|
|
|
|
expect(update).toHaveBeenCalledWith({
|
|
id: "schedule-1",
|
|
cadence: { type: "cron", expression: "*/10 * * * *" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "whitespace cron field",
|
|
input: { id: "schedule-1", every: "10m", cron: " " },
|
|
cadence: { type: "cron", expression: "*/10 * * * *" },
|
|
},
|
|
{
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
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: scheduleServiceWithUpdate(update),
|
|
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: scheduleServiceWithUpdate(update),
|
|
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: scheduleServiceWithUpdate(update),
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
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: scheduleServiceWithUpdate(update, stored),
|
|
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: scheduleServiceWithUpdate(update),
|
|
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 inspect = vi.fn(async () => ({
|
|
id: "schedule-1",
|
|
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
|
|
}));
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
scheduleService: { logs, inspect } 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: [],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
|
|
const config = MutableDaemonConfigSchema.parse({
|
|
relay: { enabled: true },
|
|
mcp: { injectIntoAgents: true },
|
|
...(agentProfiles !== undefined ? { agentProfiles } : {}),
|
|
});
|
|
return { get: () => config };
|
|
}
|
|
|
|
describe("agent profile listing MCP tool", () => {
|
|
const logger = createTestLogger();
|
|
|
|
it("returns configured profiles, including notes", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const profiles: AgentProfile[] = [
|
|
{
|
|
id: "ui-profile",
|
|
name: "UI work",
|
|
provider: "claude",
|
|
model: "claude-test-model",
|
|
modeId: "bypassPermissions",
|
|
thinkingOptionId: "high",
|
|
featureValues: { fast_mode: true },
|
|
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
|
},
|
|
];
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
daemonConfigStore: daemonConfigStoreStub(profiles),
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "list_profiles");
|
|
|
|
const response = await tool.handler({});
|
|
|
|
expect(response.structuredContent).toEqual({ profiles });
|
|
});
|
|
|
|
it("returns an empty array when no profiles are configured", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
daemonConfigStore: daemonConfigStoreStub(),
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "list_profiles");
|
|
|
|
const response = await tool.handler({});
|
|
|
|
expect(response.structuredContent).toEqual({ profiles: [] });
|
|
});
|
|
|
|
it("returns an empty array when no daemon config store is provided", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
const tool = registeredTool(server, "list_profiles");
|
|
|
|
const response = await tool.handler({});
|
|
|
|
expect(response.structuredContent).toEqual({ profiles: [] });
|
|
});
|
|
});
|
|
|
|
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)}`,
|
|
);
|
|
}
|
|
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 agent list 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 permission 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" },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
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",
|
|
{ broadcast: expect.any(Function) },
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
it("rejects hidden agent-worktree placement and directs callers to workspace creation", async () => {
|
|
const { agentManager, agentStorage } = createTestDeps();
|
|
const server = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger: createTestLogger(),
|
|
});
|
|
const tool = registeredTool(server, "create_agent");
|
|
await expect(
|
|
tool.handler({
|
|
provider: "codex/gpt-5.4",
|
|
title: "Worktree agent",
|
|
initialPrompt: "test",
|
|
relationship: { kind: "detached" },
|
|
workspace: {
|
|
kind: "create",
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: process.cwd(),
|
|
target: { kind: "branch-off", worktreeSlug: "legacy" },
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow("create_workspace");
|
|
await expect(
|
|
tool.handler({
|
|
provider: "codex/gpt-5.4",
|
|
title: "Worktree agent",
|
|
initialPrompt: "test",
|
|
cwd: process.cwd(),
|
|
worktreeName: "legacy",
|
|
baseBranch: "main",
|
|
}),
|
|
).rejects.toThrow("create_workspace");
|
|
});
|
|
|
|
describe("local durable finish subscription MCP wiring", () => {
|
|
const logger = createTestLogger();
|
|
const existingCwd = process.cwd();
|
|
it.each(["concurrent", "repeated"] as const)(
|
|
"reports %s permission requests and final completion",
|
|
async (sequence) => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-local-permissions-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
|
const childClient = new HeldTurnAgentClient("codex", true);
|
|
const manager = new AgentManager({
|
|
clients: { claude: parentClient, codex: childClient },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
let mcp: Client | undefined;
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "claude", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: existingCwd },
|
|
undefined,
|
|
{ workspaceId: "wks_parent" },
|
|
);
|
|
const server = await createAgentMcpServer({
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
});
|
|
mcp = await connectInMemoryMcpClient(server);
|
|
await sendMcpAndAwaitDispatch(mcp, server, {
|
|
agentId: child.id,
|
|
prompt: "Permission work",
|
|
});
|
|
const prompts = () =>
|
|
parentClient.sessions[0]!.prompts.flatMap((prompt) => parseAgentMessages(prompt) ?? [])
|
|
.map((message) => message.text)
|
|
.join("\n");
|
|
const permissionIds = () =>
|
|
[...prompts().matchAll(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/g)].map(
|
|
(match) => JSON.parse(match[1]!).requestId,
|
|
);
|
|
const respond = async (requestId: string) => {
|
|
const result = await mcp!.callTool({
|
|
name: "respond_to_permission",
|
|
arguments: { agentId: child.id, requestId, response: { behavior: "allow" } },
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
};
|
|
childClient.sessions[0]!.requestPermission("first");
|
|
if (sequence === "concurrent") {
|
|
childClient.sessions[0]!.requestPermission("second");
|
|
await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"]));
|
|
await respond("first");
|
|
await respond("second");
|
|
} else {
|
|
await vi.waitFor(() => expect(permissionIds()).toEqual(["first"]));
|
|
await respond("first");
|
|
childClient.sessions[0]!.requestPermission("second");
|
|
await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"]));
|
|
await respond("second");
|
|
}
|
|
expect(serverSubscriptionRecords.get(server)!.size).toBe(1);
|
|
expect(prompts()).not.toContain("finished.");
|
|
childClient.sessions[0]!.finishTurn("Permission work complete");
|
|
await vi.waitFor(() => expect(prompts()).toContain("finished."));
|
|
expect(prompts()).toContain("Permission work complete");
|
|
expect(permissionIds()).toEqual(["first", "second"]);
|
|
await vi.waitFor(() => expect(serverSubscriptionRecords.get(server)!.size).toBe(0));
|
|
} finally {
|
|
await mcp?.close();
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("returns a tool error before sending a prompt when subscription storage cannot commit", async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-registration-failure-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldTurnAgentClient("codex", true);
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
const subscriptions = createJsonFinishSubscriptionStore(workdir);
|
|
let mcp: Client | undefined;
|
|
try {
|
|
const parent = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, {
|
|
workspaceId: "wks_parent",
|
|
});
|
|
const child = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, {
|
|
workspaceId: "wks_parent",
|
|
});
|
|
expect(await subscriptions.list()).toEqual([]);
|
|
await mkdir(join(workdir, "finish-subscriptions.json"));
|
|
const server = await createAgentMcpServer(
|
|
{
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
callerAgentId: parent.id,
|
|
providerSnapshotManager: createOpenCodeManager().manager,
|
|
logger,
|
|
},
|
|
subscriptions,
|
|
);
|
|
mcp = await connectInMemoryMcpClient(server);
|
|
const result = await mcp.callTool({
|
|
name: "send_agent_prompt",
|
|
arguments: { agentId: child.id, prompt: "Must not dispatch" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(client.sessions[1]!.prompts).toEqual([]);
|
|
expect(await subscriptions.list()).toEqual([]);
|
|
} finally {
|
|
await mcp?.close();
|
|
await removeAgentStateDir(manager, storage, workdir);
|
|
}
|
|
});
|
|
});
|