diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 2a112d387..ffc79e8ff 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -132,7 +132,7 @@ describe("agent MCP end-to-end (offline)", () => { args: { cwd: agentCwd, title: "MCP e2e smoke", - provider: "claude", + provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt, background: false, @@ -223,7 +223,7 @@ describe("agent MCP end-to-end (offline)", () => { args: { cwd: agentCwd, title: "Injected MCP", - provider: "claude", + provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", background: true, @@ -246,7 +246,7 @@ describe("agent MCP end-to-end (offline)", () => { args: { cwd: disabledAgentCwd, title: "No injected MCP", - provider: "claude", + provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", background: true, @@ -343,7 +343,7 @@ describe("agent MCP end-to-end (offline)", () => { args: { cwd: repoRoot, title: "MCP worktree setup terminals", - provider: "claude", + provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "say done and stop", worktreeName: "mcp-worktree-setup-test", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 9d14baa8f..da4cf9afd 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -1,4 +1,4 @@ -import type { AgentSnapshotPayload } from "../messages.js"; +import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js"; import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js"; import type { AgentCapabilityFlags, @@ -31,6 +31,17 @@ function normalizeThinkingOptionId(value: string | null | undefined): string | n return normalized.length > 0 ? normalized : null; } +function normalizeLabels(labels: Record | undefined): Record { + if (!labels) { + return {}; + } + return Object.fromEntries( + Object.entries(labels).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + export function resolveEffectiveThinkingOptionId(options: { runtimeInfo?: AgentRuntimeInfo | null; configuredThinkingOptionId?: string | null; @@ -198,7 +209,29 @@ export function buildStoredAgentPayload( attentionReason: record.attentionReason ?? null, attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, - labels: record.labels, + labels: normalizeLabels(record.labels), + }; +} + +export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListItemPayload { + return { + id: agent.id, + shortId: agent.id.slice(0, 7), + title: agent.title, + provider: agent.provider, + model: agent.runtimeInfo?.model ?? agent.model, + thinkingOptionId: agent.thinkingOptionId, + effectiveThinkingOptionId: agent.effectiveThinkingOptionId, + status: agent.status, + cwd: agent.cwd, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + lastUserMessageAt: agent.lastUserMessageAt, + archivedAt: agent.archivedAt ?? null, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + attentionTimestamp: agent.attentionTimestamp ?? null, + labels: agent.labels, }; } diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 2406eb89e..d0f48fdf6 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -121,7 +121,7 @@ describe("MCP parity end-to-end", () => { const payload = await callToolStructured(topLevelClient, "create_agent", { cwd, title: "Parity agent", - provider: "claude", + provider: "claude/claude-test-model", initialPrompt: "say done and stop", mode: "bypassPermissions", background: true, @@ -133,7 +133,7 @@ describe("MCP parity end-to-end", () => { async function createChildAgent(args?: Partial): Promise { const payload = await callToolStructured(agentScopedClient, "create_agent", { title: "Parity child", - provider: "claude", + provider: "claude/claude-test-model", initialPrompt: "say done and stop", background: true, ...args, @@ -207,7 +207,7 @@ describe("MCP parity end-to-end", () => { const parentPayload = await callToolStructured(topLevelClient, "create_agent", { cwd: parentAgentCwd, title: "MCP parity parent", - provider: "claude", + provider: "claude/claude-test-model", initialPrompt: "say done and stop", mode: "bypassPermissions", background: true, @@ -294,10 +294,10 @@ describe("MCP parity end-to-end", () => { } }); - test("create_agent accepts model param", async () => { + test("create_agent accepts provider/model syntax", async () => { let agentId: string | null = null; try { - agentId = await createTopLevelAgent({ model: "claude-test-model" }); + agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" }); const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(snapshot?.config.model).toBe("claude-test-model"); } finally { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8d1da3ac6..df4e70c27 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -10,8 +10,12 @@ import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; -import { AgentSnapshotPayloadSchema } from "../../shared/messages.js"; +import { + AgentListItemPayloadSchema, + AgentSnapshotPayloadSchema, +} from "../../shared/messages.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; +import type { CreateScheduleInput, StoredSchedule } from "../schedule/types.js"; import { createPaseoWorktree as createPaseoWorktreeService, type CreatePaseoWorktreeFn, @@ -125,6 +129,37 @@ function createStoredRecord(overrides: Partial = {}): StoredA }; } +function createManagedAgent(overrides: Partial = {}): ManagedAgent { + const now = new Date(); + return { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: now, + updatedAt: now, + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + ...overrides, + } as ManagedAgent; +} + function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], @@ -151,6 +186,26 @@ function createGitHubServiceStub(): GitHubService { }; } +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 createPaseoWorktreeForMcpTest(options: { paseoHome: string; broadcasts: string[]; @@ -209,6 +264,7 @@ describe("create_agent MCP tool", () => { const missingTitle = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, mode: "default", + provider: "codex/gpt-5.4", initialPrompt: "test", }); expect(missingTitle.success).toBe(false); @@ -217,6 +273,7 @@ describe("create_agent MCP tool", () => { const tooLong = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, mode: "default", + provider: "codex/gpt-5.4", title: "x".repeat(61), initialPrompt: "test", }); @@ -226,6 +283,7 @@ describe("create_agent MCP tool", () => { const ok = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, mode: "default", + provider: "codex/gpt-5.4", title: "Short title", initialPrompt: "test", }); @@ -239,6 +297,7 @@ describe("create_agent MCP tool", () => { const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, mode: "default", + provider: "codex/gpt-5.4", title: "Short title", }); expect(parsed.success).toBe(false); @@ -247,6 +306,63 @@ describe("create_agent MCP tool", () => { ).toBe(true); }); + it("requires provider as provider/model and rejects the old model field", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["create_agent"]; + + const missingProvider = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + mode: "default", + title: "Short title", + initialPrompt: "test", + }); + expect(missingProvider.success).toBe(false); + expect( + missingProvider.error.issues.some( + (issue: { path: string[] }) => issue.path[0] === "provider", + ), + ).toBe(true); + + const providerWithoutModel = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + mode: "default", + title: "Short title", + provider: "codex", + initialPrompt: "test", + }); + expect(providerWithoutModel.success).toBe(false); + + const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + mode: "default", + title: "Short title", + provider: "codex/", + initialPrompt: "test", + }); + expect(providerWithEmptyModel.success).toBe(false); + + const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + mode: "default", + title: "Short title", + provider: "/gpt-5.4", + initialPrompt: "test", + }); + expect(providerWithEmptyProvider.success).toBe(false); + + await expect( + tool.handler({ + cwd: existingCwd, + mode: "default", + title: "Short title", + provider: "codex/gpt-5.4", + model: "gpt-5.4", + initialPrompt: "test", + }), + ).rejects.toThrow("Unrecognized key"); + }); + it("accepts optional worktree intent fields in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); @@ -255,6 +371,7 @@ describe("create_agent MCP tool", () => { const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, title: "Short title", + provider: "codex/gpt-5.4", initialPrompt: "test", worktreeName: "review-42", action: "checkout", @@ -285,7 +402,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_worktree"]; - await expect(tool.callback({})).rejects.toThrow( + await expect(tool.handler({})).rejects.toThrow( "create_worktree requires branchName, refName, or githubPrNumber", ); }); @@ -299,9 +416,10 @@ describe("create_agent MCP tool", () => { const tool = (server as any)._registeredTools["create_agent"]; await expect( - tool.callback({ + tool.handler({ cwd: "/path/that/does/not/exist", title: "Short title", + provider: "codex/gpt-5.4", initialPrompt: "Do work", }), ).rejects.toThrow("Working directory does not exist"); @@ -320,9 +438,10 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: existingCwd, title: " Fix auth bug ", + provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -349,9 +468,10 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: existingCwd, title: " Fix auth ", + provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -364,7 +484,7 @@ describe("create_agent MCP tool", () => { ); }); - it("passes optional model, thinking, and labels through createAgent", async () => { + it("requires provider/model and passes thinking and labels through createAgent", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "agent-789", @@ -377,12 +497,12 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: existingCwd, title: "Config test", mode: "default", initialPrompt: "Do work", - model: "claude-sonnet-4-20250514", + provider: "codex/gpt-5.4", thinking: "think-hard", labels: { source: "mcp" }, }); @@ -391,7 +511,8 @@ describe("create_agent MCP tool", () => { expect.objectContaining({ cwd: existingCwd, title: "Config test", - model: "claude-sonnet-4-20250514", + provider: "codex", + model: "gpt-5.4", thinkingOptionId: "think-hard", }), undefined, @@ -411,6 +532,7 @@ describe("create_agent MCP tool", () => { execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); await writeFile(join(repoDir, "README.md"), "hello\n"); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); @@ -437,9 +559,10 @@ describe("create_agent MCP tool", () => { logger, }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: repoDir, title: "Worktree agent", + provider: "codex/gpt-5.4", initialPrompt: "Do work", worktreeName: "agent-worktree", baseBranch: "main", @@ -472,6 +595,7 @@ describe("create_agent MCP tool", () => { execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); await writeFile(join(repoDir, "README.md"), "hello\n"); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); @@ -489,7 +613,7 @@ describe("create_agent MCP tool", () => { logger, }); const tool = (server as any)._registeredTools["create_worktree"]; - const response = await tool.callback({ + const response = await tool.handler({ cwd: repoDir, branchName: "tool-worktree", baseBranch: "main", @@ -525,6 +649,7 @@ describe("create_agent MCP tool", () => { execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); await writeFile(join(repoDir, "README.md"), "hello\n"); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); @@ -543,14 +668,14 @@ describe("create_agent MCP tool", () => { }); const createTool = (server as any)._registeredTools["create_worktree"]; const archiveTool = (server as any)._registeredTools["archive_worktree"]; - const created = await createTool.callback({ + const created = await createTool.handler({ cwd: repoDir, branchName: "archive-tool-worktree", baseBranch: "main", }); workspaceGitService.getSnapshot.mockClear(); - await archiveTool.callback({ + await archiveTool.handler({ cwd: repoDir, worktreePath: created.structuredContent.worktreePath, }); @@ -584,7 +709,7 @@ describe("create_agent MCP tool", () => { }); const tool = (server as any)._registeredTools["list_worktrees"]; - const response = await tool.callback({ cwd: "/tmp/repo" }); + const response = await tool.handler({ cwd: "/tmp/repo" }); expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", { reason: "mcp:list-worktrees", @@ -606,8 +731,8 @@ describe("create_agent MCP tool", () => { const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, title: "Custom provider agent", - initialMode: "default", - agentType: "zai", + mode: "default", + provider: "zai/custom-model", initialPrompt: "Do work", }); @@ -646,10 +771,10 @@ describe("create_agent MCP tool", () => { }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: "subdir", title: "Child", - provider: "codex", + provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -685,10 +810,11 @@ describe("create_agent MCP tool", () => { logger, }); const tool = (server as any)._registeredTools["create_agent"]; - await tool.callback({ + await tool.handler({ cwd: existingCwd, title: "Injected config test", mode: "default", + provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -703,6 +829,97 @@ describe("create_agent MCP tool", () => { }); }); +describe("create_schedule MCP tool", () => { + const logger = createTestLogger(); + + it("preserves default new-agent schedule behavior without requiring provider", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { create } as any, + logger, + }); + const tool = (server as any)._registeredTools["create_schedule"]; + + const response = await tool.handler({ + prompt: "say hello", + every: "5m", + name: "Default schedule", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "say hello", + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: process.cwd(), + }, + }, + }), + ); + expect(response.structuredContent.target).toEqual({ + type: "new-agent", + config: { + provider: "claude", + cwd: process.cwd(), + }, + }); + }); + + it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { create } as any, + logger, + }); + const tool = (server as any)._registeredTools["create_schedule"]; + + await tool.handler({ + prompt: "say hello", + every: "5m", + provider: "codex", + }); + await tool.handler({ + prompt: "say hello again", + every: "10m", + provider: "codex/gpt-5.4", + }); + + expect(create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: { + type: "new-agent", + config: { + provider: "codex", + cwd: process.cwd(), + }, + }, + }), + ); + expect(create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + target: { + type: "new-agent", + config: { + provider: "codex", + cwd: process.cwd(), + model: "gpt-5.4", + }, + }, + }), + ); + }); +}); + describe("provider listing MCP tool", () => { const logger = createTestLogger(); @@ -730,7 +947,7 @@ describe("provider listing MCP tool", () => { logger, }); const tool = (server as any)._registeredTools["list_providers"]; - const response = await tool.callback({}); + const response = await tool.handler({}); expect(response.structuredContent).toEqual({ providers: [ @@ -766,7 +983,7 @@ describe("speak MCP tool", () => { const tool = (server as any)._registeredTools["speak"]; expect(tool).toBeDefined(); - await tool.callback({ text: "Hello from voice agent." }); + await tool.handler({ text: "Hello from voice agent." }); expect(speak).toHaveBeenCalledWith( expect.objectContaining({ text: "Hello from voice agent.", @@ -786,7 +1003,7 @@ describe("speak MCP tool", () => { logger, }); const tool = (server as any)._registeredTools["speak"]; - await expect(tool.callback({ text: "Hello." })).rejects.toThrow( + await expect(tool.handler({ text: "Hello." })).rejects.toThrow( "No speak handler registered for caller agent", ); }); @@ -807,51 +1024,53 @@ describe("speak MCP tool", () => { describe("agent snapshot MCP serialization", () => { const logger = createTestLogger(); - it("normalizes null features to an empty array for list_agents", async () => { + it("returns compact list items from list_agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.listAgents = vi.fn().mockReturnValue([ - { - id: "agent-null-features", - provider: "claude", + createManagedAgent({ + id: "agent-compact", + provider: "codex", cwd: "/tmp/repo", - config: {}, - runtimeInfo: undefined, - createdAt: new Date("2026-04-11T00:00:00.000Z"), - updatedAt: new Date("2026-04-11T00:00:00.000Z"), - lastUserMessageAt: null, - lifecycle: "idle", - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: true, - supportsReasoningStream: false, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - features: null, - pendingPermissions: new Map(), - persistence: null, - labels: {}, - attention: { requiresAttention: false }, - } as unknown as ManagedAgent, + 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, logger }); const tool = (server as any)._registeredTools["list_agents"]; - const response = await tool.callback({}); + const response = await tool.handler({}); const structured = response.structuredContent; expect(structured).toEqual({ agents: [ - expect.objectContaining({ - id: "agent-null-features", - features: [], - }), + { + id: "agent-compact", + shortId: "agent-c", + title: null, + provider: "codex", + model: "gpt-5.4", + thinkingOptionId: "high", + effectiveThinkingOptionId: "high", + status: "idle", + cwd: "/tmp/repo", + createdAt: expect.any(String), + updatedAt: expect.any(String), + lastUserMessageAt: null, + archivedAt: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + labels: { role: "researcher" }, + }, ], }); - expect(Array.isArray(structured.agents[0].features)).toBe(true); + 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 () => { @@ -872,7 +1091,7 @@ describe("agent snapshot MCP serialization", () => { } as any, }); const tool = (server as any)._registeredTools["get_agent_status"]; - const response = await tool.callback({ agentId: "archived-agent" }); + const response = await tool.handler({ agentId: "archived-agent" }); expect(response.structuredContent).toEqual({ status: "closed", @@ -886,6 +1105,104 @@ describe("agent snapshot MCP serialization", () => { 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", + }, + }), + ); + + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["get_agent_status"]; + const response = await tool.handler({ agentId: "full-detail-agent" }); + const snapshot = 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); @@ -906,44 +1223,37 @@ describe("agent snapshot MCP serialization", () => { }); const tool = (server as any)._registeredTools["get_agent_status"]; - await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow( + await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow( "Agent internal-agent not found", ); }); - it("includes stored non-archived agents in list_agents by default", async () => { + it("defaults list_agents to caller cwd and excludes archived agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const liveAgent = { - id: "live-agent", - provider: "claude", - cwd: "/tmp/live-project", - config: {}, - runtimeInfo: undefined, - createdAt: new Date("2026-04-11T00:00:00.000Z"), - updatedAt: new Date("2026-04-11T00:00:00.000Z"), - 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 }, - } as unknown as ManagedAgent; - spies.agentManager.listAgents.mockReturnValue([liveAgent]); + 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: "closed-agent", archivedAt: null }), - createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), - createStoredRecord({ id: "live-agent", archivedAt: null }), + 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 }), ]); @@ -954,54 +1264,48 @@ describe("agent snapshot MCP serialization", () => { providerRegistry: { claude: createProviderDefinition({}), } as any, + callerAgentId: "caller-agent", }); const tool = (server as any)._registeredTools["list_agents"]; - const response = await tool.callback({}); + const response = await tool.handler({}); - expect(response.structuredContent.agents).toEqual([ - expect.objectContaining({ id: "live-agent" }), - expect.objectContaining({ id: "closed-agent", archivedAt: null }), + expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([ + "in-cwd", + "in-child-cwd", + "stored-in-cwd", ]); }); - it("includes archived stored agents in list_agents when requested", async () => { + it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const liveAgent = { - id: "live-agent", - provider: "claude", - cwd: "/tmp/live-project", - config: {}, - runtimeInfo: undefined, - createdAt: new Date("2026-04-11T00:00:00.000Z"), - updatedAt: new Date("2026-04-11T00:00:00.000Z"), - 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 }, - } as unknown as ManagedAgent; - spies.agentManager.listAgents.mockReturnValue([liveAgent]); - spies.agentStorage.list.mockResolvedValue([ - createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), - createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), - createStoredRecord({ - id: "internal-archived-agent", - archivedAt: "2026-04-12T00:00:00.000Z", - internal: true, + 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: "/tmp/target", + lifecycle: "running", + updatedAt: new Date(recent), }), - createStoredRecord({ id: "not-archived-agent", archivedAt: null }), + createManagedAgent({ + id: "idle-target", + cwd: "/tmp/target", + lifecycle: "idle", + updatedAt: new Date(recent), + }), + createManagedAgent({ + id: "old-running-target", + cwd: "/tmp/target", + lifecycle: "running", + createdAt: new Date(old), + updatedAt: new Date(old), + }), + ]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }), + createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }), + createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }), ]); const server = await createAgentMcpServer({ @@ -1013,53 +1317,187 @@ describe("agent snapshot MCP serialization", () => { } as any, }); const tool = (server as any)._registeredTools["list_agents"]; - const response = await tool.callback({ includeArchived: true }); + const response = await tool.handler({ + cwd: "/tmp/target", + includeArchived: true, + sinceHours: 48, + statuses: ["running", "closed"], + limit: 3, + }); - expect(response.structuredContent.agents).toEqual([ - expect.objectContaining({ id: "live-agent" }), - expect.objectContaining({ - id: "archived-agent", - archivedAt: "2026-04-12T00:00:00.000Z", + expect(response.structuredContent.agents.map((agent: { id: string }) => 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(), }), - expect.objectContaining({ - id: "not-archived-agent", - archivedAt: null, + ); + 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, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.handler({ includeArchived: true }); + const agentIds = response.structuredContent.agents.map((agent: { id: string }) => 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: "/tmp/repo", + updatedAt: now, + lastActivityAt: now, + archivedAt: now, + features: [ + { + type: "toggle", + id: "danger-zone", + label: "Danger zone", + value: false, + }, + ], + }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.handler({ cwd: "/tmp/repo", includeArchived: true }); + const item = response.structuredContent.agents[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: "/tmp/repo", + 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, logger }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.handler({}); + + expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([ + "idle-attention-oldest", + "running-older", + "initializing-middle", + "idle-recent", + "error-recent", + "closed-newest", + ]); }); it("emits list_agents payloads that satisfy the declared output schema", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const liveAgent = { - id: "live-agent", - provider: "claude", - cwd: "/tmp/live-project", - config: {}, - runtimeInfo: undefined, - createdAt: new Date("2026-04-11T00:00:00.000Z"), - updatedAt: new Date("2026-04-11T00:00:00.000Z"), - 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 }, - } as unknown as ManagedAgent; - spies.agentManager.listAgents.mockReturnValue([liveAgent]); + const now = new Date().toISOString(); + spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]); spies.agentStorage.list.mockResolvedValue([ - createStoredRecord({ id: "stored-non-archived", archivedAt: null }), - createStoredRecord({ id: "stored-archived", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ + id: "stored-non-archived", + updatedAt: now, + lastActivityAt: now, + archivedAt: null, + }), + createStoredRecord({ id: "stored-archived", archivedAt: now }), ]); const server = await createAgentMcpServer({ @@ -1071,12 +1509,12 @@ describe("agent snapshot MCP serialization", () => { } as any, }); const tool = (server as any)._registeredTools["list_agents"]; - const response = await tool.callback({ includeArchived: true }); + const response = await tool.handler({ includeArchived: true }); - const parsed = z.array(AgentSnapshotPayloadSchema).safeParse(response.structuredContent.agents); + const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents); if (!parsed.success) { throw new Error( - `list_agents response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`, + `list_agents response failed AgentListItemPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`, ); } }); @@ -1111,7 +1549,7 @@ describe("agent snapshot MCP serialization", () => { } as any, }); const tool = (server as any)._registeredTools["get_agent_activity"]; - const response = await tool.callback({ agentId: "archived-activity-agent" }); + const response = await tool.handler({ agentId: "archived-activity-agent" }); expect(response.structuredContent).toEqual( expect.objectContaining({ diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index e358e29ad..555c55080 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -9,10 +9,16 @@ import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import { AgentPermissionRequestPayloadSchema, + AgentListItemPayloadSchema, AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, } from "../messages.js"; -import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js"; +import type { AgentListItemPayload } from "../messages.js"; +import { + buildStoredAgentPayload, + toAgentListItemPayload, + toAgentPayload, +} from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; @@ -24,7 +30,7 @@ import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.j import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; -import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; +import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { captureTerminalLines } from "../../terminal/terminal.js"; import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; @@ -38,7 +44,7 @@ import { AgentStatusEnum, ProviderSummarySchema, parseDurationString, - resolveProviderAndModel, + resolveRequiredProviderModel, sanitizePermissionRequest, sendPromptToAgent, setupFinishNotification, @@ -126,6 +132,68 @@ function mapModeAcrossProviders( type McpToolContext = RequestHandlerExtra; +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function resolveAgentListActivityTime(agent: AgentListItemPayload): number { + return Math.max( + parseTimestamp(agent.updatedAt), + parseTimestamp(agent.lastUserMessageAt), + parseTimestamp(agent.attentionTimestamp), + parseTimestamp(agent.archivedAt), + parseTimestamp(agent.createdAt), + ); +} + +function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { + const attentionDelta = + Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); + if (attentionDelta !== 0) { + return attentionDelta; + } + + const statusOrder = { + running: 0, + initializing: 1, + idle: 2, + error: 3, + closed: 4, + } as Record; + const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); + if (statusDelta !== 0) { + return statusDelta; + } + + return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); +} + +function resolveScheduleProviderAndModel(params: { + provider?: string; + defaultProvider: AgentProvider; +}): { provider: AgentProvider; model?: string } { + const providerInput = params.provider?.trim() || params.defaultProvider; + const slashIndex = providerInput.indexOf("/"); + if (slashIndex === -1) { + return { provider: providerInput as AgentProvider }; + } + + const provider = providerInput.slice(0, slashIndex).trim(); + const model = providerInput.slice(slashIndex + 1).trim(); + if (!provider || !model) { + throw new Error("provider must be or /"); + } + + return { + provider: provider as AgentProvider, + model, + }; +} + function resolveChildAgentCwd(params: { parentCwd: string; requestedCwd?: string; @@ -257,7 +325,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (callerAgent) { const hasProviderOverride = params?.provider !== undefined; const resolvedProviderModel = hasProviderOverride - ? resolveProviderAndModel({ + ? resolveScheduleProviderAndModel({ provider: params?.provider, defaultProvider: callerAgent.provider, }) @@ -307,21 +375,34 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }; } + const resolvedProviderModel = resolveScheduleProviderAndModel({ + provider: params?.provider, + defaultProvider: "claude", + }); return { type: "new-agent" as const, - config: (() => { - const resolvedProviderModel = resolveProviderAndModel({ - provider: params?.provider, - defaultProvider: "claude", - }); - return { - provider: resolvedProviderModel.provider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), - ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), - }; - })(), + config: { + provider: resolvedProviderModel.provider, + cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), + ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), + }, }; }; + const ProviderModelInputSchema = AgentProviderEnum.trim() + .refine((value) => value.includes("/"), { + message: "provider must be provider/model, for example codex/gpt-5.4", + }) + .refine( + (value) => { + try { + resolveRequiredProviderModel(value); + return true; + } catch { + return false; + } + }, + { message: "provider must be provider/model, for example codex/gpt-5.4" }, + ); const agentToAgentInputSchema = { cwd: z .string() @@ -333,10 +414,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .min(1, "Title is required") .max(60, "Title must be 60 characters or fewer") .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), - provider: AgentProviderEnum.optional().describe( - "Optional agent implementation to spawn. Defaults to 'claude'.", + provider: ProviderModelInputSchema.describe( + "Required provider/model pair, for example codex/gpt-5.4.", ), - model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"), thinking: z.string().optional().describe("Thinking option ID"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), initialPrompt: z @@ -370,10 +450,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .min(1, "Title is required") .max(60, "Title must be 60 characters or fewer") .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), - provider: AgentProviderEnum.optional().describe( - "Optional agent implementation to spawn. Defaults to 'claude'.", + provider: ProviderModelInputSchema.describe( + "Required provider/model pair, for example codex/gpt-5.4.", ), - model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"), thinking: z.string().optional().describe("Thinking option ID"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), initialPrompt: z @@ -421,8 +500,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }; const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; - const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema); - const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema); + const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); + const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { server.registerTool( @@ -472,7 +551,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom { title: "Create agent", description: - "Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", + "Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Optionally run an initial prompt immediately or create a git worktree for the agent.", inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(), @@ -508,11 +587,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (callerAgentId) { const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); - provider = callerArgs.provider ?? "claude"; + const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider); + provider = resolvedProviderModel.provider; + model = resolvedProviderModel.model; initialPrompt = callerArgs.initialPrompt; background = callerArgs.background ?? false; normalizedTitle = callerArgs.title.trim(); - model = callerArgs.model; thinking = callerArgs.thinking; labels = callerArgs.labels; notifyOnFinish = callerArgs.notifyOnFinish ?? false; @@ -533,11 +613,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } } else { const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); - provider = topLevelArgs.provider ?? "claude"; + const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); + provider = resolvedProviderModel.provider; + model = resolvedProviderModel.model; initialPrompt = topLevelArgs.initialPrompt; background = topLevelArgs.background ?? false; normalizedTitle = topLevelArgs.title.trim(); - model = topLevelArgs.model; thinking = topLevelArgs.thinking; labels = topLevelArgs.labels; notifyOnFinish = topLevelArgs.notifyOnFinish ?? false; @@ -929,15 +1010,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "list_agents", { title: "List agents", - description: "List all live agents managed by the server.", + description: "List recent agents as compact metadata.", inputSchema: { includeArchived: z.boolean().optional().default(false), + cwd: z.string().optional(), + sinceHours: z + .number() + .int() + .positive() + .max(24 * 30) + .optional() + .default(48), + statuses: z.array(AgentStatusEnum).optional(), + limit: z.number().int().positive().max(200).optional().default(50), }, outputSchema: { - agents: z.array(AgentSnapshotPayloadSchema), + agents: z.array(AgentListItemPayloadSchema), }, }, - async ({ includeArchived }) => { + async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; + const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; + const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; + const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; const liveSnapshots = agentManager.listAgents(); const liveAgents = await Promise.all( liveSnapshots.map((snapshot) => @@ -950,10 +1045,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .filter((record) => !record.internal && !liveIds.has(record.id)) .filter((record) => includeArchived || !record.archivedAt) .map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger)); + const agents = [...liveAgents, ...storedAgents] + .map(toAgentListItemPayload) + .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) + .filter((agent) => !statusFilter || statusFilter.has(agent.status)) + .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) + .sort(compareAgentListItems) + .slice(0, limit); return { content: [], - structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }), + structuredContent: ensureValidJson({ agents }), }; }, ); @@ -1321,7 +1423,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } return { type: "agent" as const, agentId: callerAgentId }; })() - : resolveNewAgentScheduleTarget({ provider, cwd }); + : (() => { + return resolveNewAgentScheduleTarget({ provider, cwd }); + })(); const schedule = await scheduleService.create({ prompt: prompt.trim(), diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index d34ab310c..a910e74ed 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -42,7 +42,7 @@ export const AgentSelectOptionSchema = z label: z.string().nullish(), description: z.string().nullish(), isDefault: z.boolean().nullish(), - metadata: z.record(z.unknown()).nullish(), + metadata: z.record(z.string(), z.unknown()).nullish(), }) .passthrough(); @@ -53,7 +53,7 @@ export const AgentModelSchema = z label: z.string().nullish(), description: z.string().nullish(), isDefault: z.boolean().nullish(), - metadata: z.record(z.unknown()).nullish(), + metadata: z.record(z.string(), z.unknown()).nullish(), thinkingOptions: z.array(AgentSelectOptionSchema).nullish(), defaultThinkingOptionId: z.string().nullish(), }) @@ -67,41 +67,24 @@ export interface ResolvedProviderModel { model: string | undefined; } -export function resolveProviderAndModel(params: { - provider?: string; - model?: string; - defaultProvider: AgentProvider; -}): ResolvedProviderModel { - const providerInput = params.provider?.trim() || params.defaultProvider; - const modelInput = params.model?.trim(); - - if (params.model !== undefined && !modelInput) { - throw new Error("model cannot be empty"); - } - +export function resolveRequiredProviderModel( + providerValue: string, +): Required { + const providerInput = providerValue.trim(); const slashIndex = providerInput.indexOf("/"); - if (slashIndex === -1) { - return { - provider: providerInput as AgentProvider, - model: modelInput, - }; + if (slashIndex <= 0 || slashIndex === providerInput.length - 1) { + throw new Error("provider must be provider/model, for example codex/gpt-5.4"); } const provider = providerInput.slice(0, slashIndex).trim(); - const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); - if (!provider || !modelFromProvider) { - throw new Error("provider must be or /"); - } - - if (modelInput && modelInput !== modelFromProvider) { - throw new Error( - `Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`, - ); + const model = providerInput.slice(slashIndex + 1).trim(); + if (!provider || !model) { + throw new Error("provider must be provider/model, for example codex/gpt-5.4"); } return { provider: provider as AgentProvider, - model: modelInput ?? modelFromProvider, + model, }; } diff --git a/packages/server/src/server/path-utils.ts b/packages/server/src/server/path-utils.ts index 76403ef36..84fe069ac 100644 --- a/packages/server/src/server/path-utils.ts +++ b/packages/server/src/server/path-utils.ts @@ -20,3 +20,17 @@ export function resolvePathFromBase(baseCwd: string, requestedPath: string): str } return resolve(baseCwd, trimmed); } + +export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean { + let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, ""); + let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, ""); + + if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) { + normalizedBase = normalizedBase.toLowerCase(); + normalizedCandidate = normalizedCandidate.toLowerCase(); + } + + return ( + normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/") + ); +} diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index d92f7f07d..c0cec4583 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -397,7 +397,7 @@ const ToolCallBasePayloadSchema = z callId: z.string(), name: z.string(), detail: ToolCallDetailPayloadSchema, - metadata: z.record(z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }) .strict(); @@ -534,7 +534,7 @@ const AgentPersistenceHandleSchema: z.ZodType = z provider: AgentProviderSchema, sessionId: z.string(), nativeHandle: z.string().optional(), - metadata: z.record(z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }) .nullable(); @@ -544,7 +544,7 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), modeId: z.string().nullable().optional(), - extra: z.record(z.unknown()).optional(), + extra: z.record(z.string(), z.unknown()).optional(), }); export const AgentSnapshotPayloadSchema = z.object({ @@ -568,7 +568,7 @@ export const AgentSnapshotPayloadSchema = z.object({ lastUsage: AgentUsageSchema.optional(), lastError: z.string().optional(), title: z.string().nullable(), - labels: z.record(z.string()).default({}), + labels: z.record(z.string(), z.string()).default({}), requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), @@ -577,6 +577,28 @@ export const AgentSnapshotPayloadSchema = z.object({ export type AgentSnapshotPayload = z.infer; +export const AgentListItemPayloadSchema = z.object({ + id: z.string(), + shortId: z.string(), + title: z.string().nullable(), + provider: AgentProviderSchema, + model: z.string().nullable(), + thinkingOptionId: z.string().nullable().optional(), + effectiveThinkingOptionId: z.string().nullable().optional(), + status: AgentStatusSchema, + cwd: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + lastUserMessageAt: z.string().nullable(), + archivedAt: z.string().nullable().optional(), + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: z.string().nullable().optional(), + labels: z.record(z.string(), z.string()).default({}), +}); + +export type AgentListItemPayload = z.infer; + export type AgentStreamEventPayload = z.infer; // ============================================================================ diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d719b5bd9..79303f8b2 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,7 @@ import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; -import { TTLCache } from "@isaacs/ttlcache"; +import TTLCache from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import {